Adds support for encrypted license keys (#335)

* implement encrypted key logic

* cache public key

* add SOURCEBOT_PUBLIC_KEY_PATH to docs

* feedback
This commit is contained in:
Michael Sukkarieh 2025-06-05 22:18:52 -07:00 committed by GitHub
parent e5c6941d69
commit 397262ecf7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 63 additions and 4 deletions

View file

@ -24,6 +24,7 @@ AUTH_URL="http://localhost:3000"
# AUTH_EE_GOOGLE_CLIENT_SECRET="" # AUTH_EE_GOOGLE_CLIENT_SECRET=""
DATA_CACHE_DIR=${PWD}/.sourcebot # Path to the sourcebot cache dir (ex. ~/sourcebot/.sourcebot) 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) # CONFIG_PATH=${PWD}/config.json # Path to the sourcebot config file (if one exists)
# Email # Email

View file

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added seperate page for signup. [#311](https://github.com/sourcebot-dev/sourcebot/pull/331) - 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 ## [4.1.1] - 2025-06-03

View file

@ -174,6 +174,7 @@ ENV REDIS_DATA_DIR=$DATA_CACHE_DIR/redis
ENV DATABASE_URL="postgresql://postgres@localhost:5432/sourcebot" ENV DATABASE_URL="postgresql://postgres@localhost:5432/sourcebot"
ENV REDIS_URL="redis://localhost:6379" ENV REDIS_URL="redis://localhost:6379"
ENV SRC_TENANT_ENFORCEMENT_MODE=strict ENV SRC_TENANT_ENFORCEMENT_MODE=strict
ENV SOURCEBOT_PUBLIC_KEY_PATH=/app/public.pem
# Valid values are: debug, info, warn, error # Valid values are: debug, info, warn, error
ENV SOURCEBOT_LOG_LEVEL=info 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. # Sourcebot collects anonymous usage data using [PostHog](https://posthog.com/). Uncomment this line to disable.
# ENV SOURCEBOT_TELEMETRY_DISABLED=1 # ENV SOURCEBOT_TELEMETRY_DISABLED=1
COPY package.json yarn.lock* .yarnrc.yml ./ COPY package.json yarn.lock* .yarnrc.yml public.pem ./
COPY .yarn ./.yarn COPY .yarn ./.yarn
# Configure zoekt # Configure zoekt

View file

@ -26,6 +26,7 @@ The following environment variables allow you to configure your Sourcebot deploy
| `SHARD_MAX_MATCH_COUNT` | `10000` | <p>The maximum shard count per query</p> | | `SHARD_MAX_MATCH_COUNT` | `10000` | <p>The maximum shard count per query</p> |
| `SMTP_CONNECTION_URL` | `-` | <p>The url to the SMTP service used for sending transactional emails. See [this doc](/docs/configuration/transactional-emails) for more info.</p> | | `SMTP_CONNECTION_URL` | `-` | <p>The url to the SMTP service used for sending transactional emails. See [this doc](/docs/configuration/transactional-emails) for more info.</p> |
| `SOURCEBOT_ENCRYPTION_KEY` | Automatically generated at startup if no value is provided. Generated using `openssl rand -base64 24` | <p>Used to encrypt connection secrets and generate API keys.</p> | | `SOURCEBOT_ENCRYPTION_KEY` | Automatically generated at startup if no value is provided. Generated using `openssl rand -base64 24` | <p>Used to encrypt connection secrets and generate API keys.</p> |
| `SOURCEBOT_PUBLIC_KEY_PATH` | `/app/public.pem` | <p>Sourcebot's public key that's used to verify encrypted license key signatures.</p> |
| `SOURCEBOT_LOG_LEVEL` | `info` | <p>The Sourcebot logging level. Valid values are `debug`, `info`, `warn`, `error`, in order of severity.</p> | | `SOURCEBOT_LOG_LEVEL` | `info` | <p>The Sourcebot logging level. Valid values are `debug`, `info`, `warn`, `error`, in order of severity.</p> |
| `SOURCEBOT_STRUCTURED_LOGGING_ENABLED` | `false` | <p>Enables/disable structured JSON logging. See [this doc](/docs/configuration/structured-logging) for more info.</p> | | `SOURCEBOT_STRUCTURED_LOGGING_ENABLED` | `false` | <p>Enables/disable structured JSON logging. See [this doc](/docs/configuration/structured-logging) for more info.</p> |
| `SOURCEBOT_STRUCTURED_LOGGING_FILE` | - | <p>Optional file to log to if structured logging is enabled</p> | | `SOURCEBOT_STRUCTURED_LOGGING_FILE` | - | <p>Optional file to log to if structured logging is enabled</p> |

View file

@ -1,9 +1,12 @@
import crypto from 'crypto'; import crypto from 'crypto';
import fs from 'fs';
import { SOURCEBOT_ENCRYPTION_KEY } from './environment'; import { SOURCEBOT_ENCRYPTION_KEY } from './environment';
const algorithm = 'aes-256-cbc'; const algorithm = 'aes-256-cbc';
const ivLength = 16; // 16 bytes for CBC const ivLength = 16; // 16 bytes for CBC
const publicKeyCache = new Map<string, string>();
const generateIV = (): Buffer => { const generateIV = (): Buffer => {
return crypto.randomBytes(ivLength); return crypto.randomBytes(ivLength);
}; };
@ -63,3 +66,28 @@ export function decrypt(iv: string, encryptedText: string): string {
return decrypted; 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;
}
}

View file

@ -109,12 +109,13 @@ export default async function LicensePage({ params: { domain } }: LicensePagePro
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="text-sm text-muted-foreground">Expiry Date</div> <div className="text-sm text-muted-foreground">Expiry Date</div>
<div className={`text-sm font-mono ${isExpired ? 'text-destructive' : ''}`}> <div className={`text-sm font-mono ${isExpired ? 'text-destructive' : ''}`}>
{expiryDate.toLocaleDateString("en-US", { {expiryDate.toLocaleString("en-US", {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
month: "long", month: "long",
day: "numeric", day: "numeric",
year: "numeric" year: "numeric",
timeZoneName: "short"
})} {isExpired && '(Expired)'} })} {isExpired && '(Expired)'}
</div> </div>
</div> </div>

View file

@ -55,6 +55,8 @@ export const env = createEnv({
DATA_CACHE_DIR: z.string(), DATA_CACHE_DIR: z.string(),
SOURCEBOT_PUBLIC_KEY_PATH: z.string(),
// Email // Email
SMTP_CONNECTION_URL: z.string().url().optional(), SMTP_CONNECTION_URL: z.string().url().optional(),
EMAIL_FROM_ADDRESS: z.string().email().optional(), EMAIL_FROM_ADDRESS: z.string().email().optional(),

View file

@ -4,6 +4,7 @@ import { base64Decode } from "@/lib/utils";
import { z } from "zod"; import { z } from "zod";
import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"; import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants";
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
import { verifySignature } from "@sourcebot/crypto";
const logger = createLogger('entitlements'); const logger = createLogger('entitlements');
@ -15,6 +16,7 @@ const eeLicenseKeyPayloadSchema = z.object({
seats: z.number(), seats: z.number(),
// ISO 8601 date string // ISO 8601 date string
expiryDate: z.string().datetime(), expiryDate: z.string().datetime(),
sig: z.string(),
}); });
type LicenseKeyPayload = z.infer<typeof eeLicenseKeyPayloadSchema>; type LicenseKeyPayload = z.infer<typeof eeLicenseKeyPayloadSchema>;
@ -23,7 +25,26 @@ const decodeLicenseKeyPayload = (payload: string): LicenseKeyPayload => {
try { try {
const decodedPayload = base64Decode(payload); const decodedPayload = base64Decode(payload);
const payloadJson = JSON.parse(decodedPayload); 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) { } catch (error) {
logger.error(`Failed to decode license key payload: ${error}`); logger.error(`Failed to decode license key payload: ${error}`);
process.exit(1); process.exit(1);

3
public.pem Normal file
View file

@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAJ8fwB3wMcuNPput/El4bK2F8vt/algcGxC6MiJqrT+c=
-----END PUBLIC KEY-----