From 397262ecf767c9beced479b72306c19c450cdc09 Mon Sep 17 00:00:00 2001 From: Michael Sukkarieh Date: Thu, 5 Jun 2025 22:18:52 -0700 Subject: [PATCH] Adds support for encrypted license keys (#335) * implement encrypted key logic * cache public key * add SOURCEBOT_PUBLIC_KEY_PATH to docs * feedback --- .env.development | 1 + CHANGELOG.md | 1 + Dockerfile | 3 +- .../configuration/environment-variables.mdx | 1 + packages/crypto/src/index.ts | 28 +++++++++++++++++++ .../app/[domain]/settings/license/page.tsx | 5 ++-- packages/web/src/env.mjs | 2 ++ .../web/src/features/entitlements/server.ts | 23 ++++++++++++++- public.pem | 3 ++ 9 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 public.pem diff --git a/.env.development b/.env.development index 1c90cd01..0309b5fb 100644 --- a/.env.development +++ b/.env.development @@ -24,6 +24,7 @@ AUTH_URL="http://localhost:3000" # AUTH_EE_GOOGLE_CLIENT_SECRET="" DATA_CACHE_DIR=${PWD}/.sourcebot # Path to the sourcebot cache dir (ex. ~/sourcebot/.sourcebot) +SOURCEBOT_PUBLIC_KEY_PATH=${PWD}/public.pem # CONFIG_PATH=${PWD}/config.json # Path to the sourcebot config file (if one exists) # Email diff --git a/CHANGELOG.md b/CHANGELOG.md index f4e157cf..33499cb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added seperate page for signup. [#311](https://github.com/sourcebot-dev/sourcebot/pull/331) +- Added encryption logic for license keys. [#335](https://github.com/sourcebot-dev/sourcebot/pull/335) ## [4.1.1] - 2025-06-03 diff --git a/Dockerfile b/Dockerfile index c2f3c398..66c63b79 100644 --- a/Dockerfile +++ b/Dockerfile @@ -174,6 +174,7 @@ ENV REDIS_DATA_DIR=$DATA_CACHE_DIR/redis ENV DATABASE_URL="postgresql://postgres@localhost:5432/sourcebot" ENV REDIS_URL="redis://localhost:6379" ENV SRC_TENANT_ENFORCEMENT_MODE=strict +ENV SOURCEBOT_PUBLIC_KEY_PATH=/app/public.pem # Valid values are: debug, info, warn, error ENV SOURCEBOT_LOG_LEVEL=info @@ -181,7 +182,7 @@ ENV SOURCEBOT_LOG_LEVEL=info # Sourcebot collects anonymous usage data using [PostHog](https://posthog.com/). Uncomment this line to disable. # ENV SOURCEBOT_TELEMETRY_DISABLED=1 -COPY package.json yarn.lock* .yarnrc.yml ./ +COPY package.json yarn.lock* .yarnrc.yml public.pem ./ COPY .yarn ./.yarn # Configure zoekt diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx index f59b3fe0..d868619e 100644 --- a/docs/docs/configuration/environment-variables.mdx +++ b/docs/docs/configuration/environment-variables.mdx @@ -26,6 +26,7 @@ The following environment variables allow you to configure your Sourcebot deploy | `SHARD_MAX_MATCH_COUNT` | `10000` |

The maximum shard count per query

| | `SMTP_CONNECTION_URL` | `-` |

The url to the SMTP service used for sending transactional emails. See [this doc](/docs/configuration/transactional-emails) for more info.

| | `SOURCEBOT_ENCRYPTION_KEY` | Automatically generated at startup if no value is provided. Generated using `openssl rand -base64 24` |

Used to encrypt connection secrets and generate API keys.

| +| `SOURCEBOT_PUBLIC_KEY_PATH` | `/app/public.pem` |

Sourcebot's public key that's used to verify encrypted license key signatures.

| | `SOURCEBOT_LOG_LEVEL` | `info` |

The Sourcebot logging level. Valid values are `debug`, `info`, `warn`, `error`, in order of severity.

| | `SOURCEBOT_STRUCTURED_LOGGING_ENABLED` | `false` |

Enables/disable structured JSON logging. See [this doc](/docs/configuration/structured-logging) for more info.

| | `SOURCEBOT_STRUCTURED_LOGGING_FILE` | - |

Optional file to log to if structured logging is enabled

| diff --git a/packages/crypto/src/index.ts b/packages/crypto/src/index.ts index 6da542f5..7e5f4196 100644 --- a/packages/crypto/src/index.ts +++ b/packages/crypto/src/index.ts @@ -1,9 +1,12 @@ import crypto from 'crypto'; +import fs from 'fs'; import { SOURCEBOT_ENCRYPTION_KEY } from './environment'; const algorithm = 'aes-256-cbc'; const ivLength = 16; // 16 bytes for CBC +const publicKeyCache = new Map(); + const generateIV = (): Buffer => { return crypto.randomBytes(ivLength); }; @@ -63,3 +66,28 @@ export function decrypt(iv: string, encryptedText: string): string { return decrypted; } + +export function verifySignature(data: string, signature: string, publicKeyPath: string): boolean { + try { + let publicKey = publicKeyCache.get(publicKeyPath); + + if (!publicKey) { + if (!fs.existsSync(publicKeyPath)) { + throw new Error(`Public key file not found at: ${publicKeyPath}`); + } + + publicKey = fs.readFileSync(publicKeyPath, 'utf8'); + publicKeyCache.set(publicKeyPath, publicKey); + } + + // Convert base64url signature to base64 if needed + const base64Signature = signature.replace(/-/g, '+').replace(/_/g, '/'); + const paddedSignature = base64Signature + '='.repeat((4 - base64Signature.length % 4) % 4); + const signatureBuffer = Buffer.from(paddedSignature, 'base64'); + + return crypto.verify(null, Buffer.from(data, 'utf8'), publicKey, signatureBuffer); + } catch (error) { + console.error('Error verifying signature:', error); + return false; + } +} diff --git a/packages/web/src/app/[domain]/settings/license/page.tsx b/packages/web/src/app/[domain]/settings/license/page.tsx index 26601820..e4235496 100644 --- a/packages/web/src/app/[domain]/settings/license/page.tsx +++ b/packages/web/src/app/[domain]/settings/license/page.tsx @@ -109,12 +109,13 @@ export default async function LicensePage({ params: { domain } }: LicensePagePro
Expiry Date
- {expiryDate.toLocaleDateString("en-US", { + {expiryDate.toLocaleString("en-US", { hour: "2-digit", minute: "2-digit", month: "long", day: "numeric", - year: "numeric" + year: "numeric", + timeZoneName: "short" })} {isExpired && '(Expired)'}
diff --git a/packages/web/src/env.mjs b/packages/web/src/env.mjs index bd4af8c8..766ba9b2 100644 --- a/packages/web/src/env.mjs +++ b/packages/web/src/env.mjs @@ -55,6 +55,8 @@ export const env = createEnv({ DATA_CACHE_DIR: z.string(), + SOURCEBOT_PUBLIC_KEY_PATH: z.string(), + // Email SMTP_CONNECTION_URL: z.string().url().optional(), EMAIL_FROM_ADDRESS: z.string().email().optional(), diff --git a/packages/web/src/features/entitlements/server.ts b/packages/web/src/features/entitlements/server.ts index 298098fa..40490b0c 100644 --- a/packages/web/src/features/entitlements/server.ts +++ b/packages/web/src/features/entitlements/server.ts @@ -4,6 +4,7 @@ import { base64Decode } from "@/lib/utils"; import { z } from "zod"; import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"; import { createLogger } from "@sourcebot/logger"; +import { verifySignature } from "@sourcebot/crypto"; const logger = createLogger('entitlements'); @@ -15,6 +16,7 @@ const eeLicenseKeyPayloadSchema = z.object({ seats: z.number(), // ISO 8601 date string expiryDate: z.string().datetime(), + sig: z.string(), }); type LicenseKeyPayload = z.infer; @@ -23,7 +25,26 @@ const decodeLicenseKeyPayload = (payload: string): LicenseKeyPayload => { try { const decodedPayload = base64Decode(payload); const payloadJson = JSON.parse(decodedPayload); - return eeLicenseKeyPayloadSchema.parse(payloadJson); + const licenseData = eeLicenseKeyPayloadSchema.parse(payloadJson); + + if (env.SOURCEBOT_PUBLIC_KEY_PATH) { + const dataToVerify = JSON.stringify({ + expiryDate: licenseData.expiryDate, + id: licenseData.id, + seats: licenseData.seats + }); + + const isSignatureValid = verifySignature(dataToVerify, licenseData.sig, env.SOURCEBOT_PUBLIC_KEY_PATH); + if (!isSignatureValid) { + logger.error('License key signature verification failed'); + process.exit(1); + } + } else { + logger.error('No public key path provided, unable to verify license key signature'); + process.exit(1); + } + + return licenseData; } catch (error) { logger.error(`Failed to decode license key payload: ${error}`); process.exit(1); diff --git a/public.pem b/public.pem new file mode 100644 index 00000000..99e9836e --- /dev/null +++ b/public.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAJ8fwB3wMcuNPput/El4bK2F8vt/algcGxC6MiJqrT+c= +-----END PUBLIC KEY-----