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-----