add enterprise license system

This commit is contained in:
miloschwartz
2025-10-13 10:41:10 -07:00
parent 6b125bba7c
commit 37ceabdf5d
76 changed files with 3886 additions and 1931 deletions

View File

@@ -1,26 +1,17 @@
import { db } from "@server/db";
import { hostMeta, licenseKey, sites } from "@server/db";
import logger from "@server/logger";
import NodeCache from "node-cache";
import { validateJWT } from "./licenseJwt";
import { count, eq } from "drizzle-orm";
import moment from "moment";
import { db, hostMeta, HostMeta } from "@server/db";
import { setHostMeta } from "@server/lib/hostMeta";
import { encrypt, decrypt } from "@server/lib/crypto";
const keyTypes = ["HOST", "SITES"] as const;
type KeyType = (typeof keyTypes)[number];
const keyTypes = ["host"] as const;
export type LicenseKeyType = (typeof keyTypes)[number];
const keyTiers = ["PROFESSIONAL", "ENTERPRISE"] as const;
type KeyTier = (typeof keyTiers)[number];
const keyTiers = ["personal", "enterprise"] as const;
export type LicenseKeyTier = (typeof keyTiers)[number];
export type LicenseStatus = {
isHostLicensed: boolean; // Are there any license keys?
isLicenseValid: boolean; // Is the license key valid?
hostId: string; // Host ID
maxSites?: number;
usedSites?: number;
tier?: KeyTier;
tier?: LicenseKeyTier;
};
export type LicenseKeyCache = {
@@ -28,451 +19,27 @@ export type LicenseKeyCache = {
licenseKeyEncrypted: string;
valid: boolean;
iat?: Date;
type?: KeyType;
tier?: KeyTier;
numSites?: number;
};
type ActivateLicenseKeyAPIResponse = {
data: {
instanceId: string;
};
success: boolean;
error: string;
message: string;
status: number;
};
type ValidateLicenseAPIResponse = {
data: {
licenseKeys: {
[key: string]: string;
};
};
success: boolean;
error: string;
message: string;
status: number;
};
type TokenPayload = {
valid: boolean;
type: KeyType;
tier: KeyTier;
quantity: number;
terminateAt: string; // ISO
iat: number; // Issued at
type?: LicenseKeyType;
tier?: LicenseKeyTier;
terminateAt?: Date;
};
export class License {
private phoneHomeInterval = 6 * 60 * 60; // 6 hours = 6 * 60 * 60 = 21600 seconds
private validationServerUrl =
"https://api.fossorial.io/api/v1/license/professional/validate";
private activationServerUrl =
"https://api.fossorial.io/api/v1/license/professional/activate";
private statusCache = new NodeCache({ stdTTL: this.phoneHomeInterval });
private licenseKeyCache = new NodeCache();
private ephemeralKey!: string;
private statusKey = "status";
private serverSecret!: string;
private publicKey = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx9RKc8cw+G8r7h/xeozF
FNkRDggQfYO6Ae+EWHGujZ9WYAZ10spLh9F/zoLhhr3XhsjpoRXwMfgNuO5HstWf
CYM20I0l7EUUMWEyWd4tZLd+5XQ4jY5xWOCWyFJAGQSp7flcRmxdfde+l+xg9eKl
apbY84aVp09/GqM96hCS+CsQZrhohu/aOqYVB/eAhF01qsbmiZ7Y3WtdhTldveYt
h4mZWGmjf8d/aEgePf/tk1gp0BUxf+Ae5yqoAqU+6aiFbjJ7q1kgxc18PWFGfE9y
zSk+OZk887N5ThQ52154+oOUCMMR2Y3t5OH1hVZod51vuY2u5LsQXsf+87PwB91y
LQIDAQAB
-----END PUBLIC KEY-----`;
constructor(private hostMeta: HostMeta) {}
constructor(private hostId: string) {
this.ephemeralKey = Buffer.from(
JSON.stringify({ ts: new Date().toISOString() })
).toString("base64");
setInterval(
async () => {
await this.check();
},
1000 * 60 * 60
); // 1 hour = 60 * 60 = 3600 seconds
}
public listKeys(): LicenseKeyCache[] {
const keys = this.licenseKeyCache.keys();
return keys.map((key) => {
return this.licenseKeyCache.get<LicenseKeyCache>(key)!;
});
public async check(): Promise<LicenseStatus> {
return {
hostId: this.hostMeta.hostMetaId,
isHostLicensed: false,
isLicenseValid: false
};
}
public setServerSecret(secret: string) {
this.serverSecret = secret;
}
public async forceRecheck() {
this.statusCache.flushAll();
this.licenseKeyCache.flushAll();
return await this.check();
}
public async isUnlocked(): Promise<boolean> {
const status = await this.check();
if (status.isHostLicensed) {
if (status.isLicenseValid) {
return true;
}
}
return false;
}
public async check(): Promise<LicenseStatus> {
// Set used sites
const [siteCount] = await db
.select({
value: count()
})
.from(sites);
const status: LicenseStatus = {
hostId: this.hostId,
isHostLicensed: true,
isLicenseValid: false,
maxSites: undefined,
usedSites: siteCount.value
};
try {
if (this.statusCache.has(this.statusKey)) {
const res = this.statusCache.get("status") as LicenseStatus;
res.usedSites = status.usedSites;
return res;
}
// Invalidate all
this.licenseKeyCache.flushAll();
const allKeysRes = await db.select().from(licenseKey);
if (allKeysRes.length === 0) {
status.isHostLicensed = false;
return status;
}
let foundHostKey = false;
// Validate stored license keys
for (const key of allKeysRes) {
try {
// Decrypt the license key and token
const decryptedKey = decrypt(
key.licenseKeyId,
this.serverSecret
);
const decryptedToken = decrypt(
key.token,
this.serverSecret
);
const payload = validateJWT<TokenPayload>(
decryptedToken,
this.publicKey
);
this.licenseKeyCache.set<LicenseKeyCache>(decryptedKey, {
licenseKey: decryptedKey,
licenseKeyEncrypted: key.licenseKeyId,
valid: payload.valid,
type: payload.type,
tier: payload.tier,
numSites: payload.quantity,
iat: new Date(payload.iat * 1000)
});
if (payload.type === "HOST") {
foundHostKey = true;
}
} catch (e) {
logger.error(
`Error validating license key: ${key.licenseKeyId}`
);
logger.error(e);
this.licenseKeyCache.set<LicenseKeyCache>(
key.licenseKeyId,
{
licenseKey: key.licenseKeyId,
licenseKeyEncrypted: key.licenseKeyId,
valid: false
}
);
}
}
if (!foundHostKey && allKeysRes.length) {
logger.debug("No host license key found");
status.isHostLicensed = false;
}
const keys = allKeysRes.map((key) => ({
licenseKey: decrypt(key.licenseKeyId, this.serverSecret),
instanceId: decrypt(key.instanceId, this.serverSecret)
}));
let apiResponse: ValidateLicenseAPIResponse | undefined;
try {
// Phone home to validate license keys
apiResponse = await this.phoneHome(keys);
if (!apiResponse?.success) {
throw new Error(apiResponse?.error);
}
} catch (e) {
logger.error("Error communicating with license server:");
logger.error(e);
}
logger.debug("Validate response", apiResponse);
// Check and update all license keys with server response
for (const key of keys) {
try {
const cached = this.licenseKeyCache.get<LicenseKeyCache>(
key.licenseKey
)!;
const licenseKeyRes =
apiResponse?.data?.licenseKeys[key.licenseKey];
if (!apiResponse || !licenseKeyRes) {
logger.debug(
`No response from server for license key: ${key.licenseKey}`
);
if (cached.iat) {
const exp = moment(cached.iat)
.add(7, "days")
.toDate();
if (exp > new Date()) {
logger.debug(
`Using cached license key: ${key.licenseKey}, valid ${cached.valid}`
);
continue;
}
}
logger.debug(
`Can't trust license key: ${key.licenseKey}`
);
cached.valid = false;
this.licenseKeyCache.set<LicenseKeyCache>(
key.licenseKey,
cached
);
continue;
}
const payload = validateJWT<TokenPayload>(
licenseKeyRes,
this.publicKey
);
cached.valid = payload.valid;
cached.type = payload.type;
cached.tier = payload.tier;
cached.numSites = payload.quantity;
cached.iat = new Date(payload.iat * 1000);
// Encrypt the updated token before storing
const encryptedKey = encrypt(
key.licenseKey,
this.serverSecret
);
const encryptedToken = encrypt(
licenseKeyRes,
this.serverSecret
);
await db
.update(licenseKey)
.set({
token: encryptedToken
})
.where(eq(licenseKey.licenseKeyId, encryptedKey));
this.licenseKeyCache.set<LicenseKeyCache>(
key.licenseKey,
cached
);
} catch (e) {
logger.error(`Error validating license key: ${key}`);
logger.error(e);
}
}
// Compute host status
for (const key of keys) {
const cached = this.licenseKeyCache.get<LicenseKeyCache>(
key.licenseKey
)!;
logger.debug("Checking key", cached);
if (cached.type === "HOST") {
status.isLicenseValid = cached.valid;
status.tier = cached.tier;
}
if (!cached.valid) {
continue;
}
if (!status.maxSites) {
status.maxSites = 0;
}
status.maxSites += cached.numSites || 0;
}
} catch (error) {
logger.error("Error checking license status:");
logger.error(error);
}
this.statusCache.set(this.statusKey, status);
return status;
}
public async activateLicenseKey(key: string) {
// Encrypt the license key before storing
const encryptedKey = encrypt(key, this.serverSecret);
const [existingKey] = await db
.select()
.from(licenseKey)
.where(eq(licenseKey.licenseKeyId, encryptedKey))
.limit(1);
if (existingKey) {
throw new Error("License key already exists");
}
let instanceId: string | undefined;
try {
// Call activate
const apiResponse = await fetch(this.activationServerUrl, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
licenseKey: key,
instanceName: this.hostId
})
});
const data = await apiResponse.json();
if (!data.success) {
throw new Error(`${data.message || data.error}`);
}
const response = data as ActivateLicenseKeyAPIResponse;
if (!response.data) {
throw new Error("No response from server");
}
if (!response.data.instanceId) {
throw new Error("No instance ID in response");
}
instanceId = response.data.instanceId;
} catch (error) {
throw Error(`Error activating license key: ${error}`);
}
// Phone home to validate license key
const keys = [
{
licenseKey: key,
instanceId: instanceId!
}
];
let validateResponse: ValidateLicenseAPIResponse;
try {
validateResponse = await this.phoneHome(keys);
if (!validateResponse) {
throw new Error("No response from server");
}
if (!validateResponse.success) {
throw new Error(validateResponse.error);
}
// Validate the license key
const licenseKeyRes = validateResponse.data.licenseKeys[key];
if (!licenseKeyRes) {
throw new Error("Invalid license key");
}
const payload = validateJWT<TokenPayload>(
licenseKeyRes,
this.publicKey
);
if (!payload.valid) {
throw new Error("Invalid license key");
}
const encryptedToken = encrypt(licenseKeyRes, this.serverSecret);
// Encrypt the instanceId before storing
const encryptedInstanceId = encrypt(instanceId!, this.serverSecret);
// Store the license key in the database
await db.insert(licenseKey).values({
licenseKeyId: encryptedKey,
token: encryptedToken,
instanceId: encryptedInstanceId
});
} catch (error) {
throw Error(`Error validating license key: ${error}`);
}
// Invalidate the cache and re-compute the status
return await this.forceRecheck();
}
private async phoneHome(
keys: {
licenseKey: string;
instanceId: string;
}[]
): Promise<ValidateLicenseAPIResponse> {
// Decrypt the instanceIds before sending to the server
const decryptedKeys = keys.map((key) => ({
licenseKey: key.licenseKey,
instanceId: key.instanceId
? decrypt(key.instanceId, this.serverSecret)
: key.instanceId
}));
const response = await fetch(this.validationServerUrl, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
licenseKeys: decryptedKeys,
ephemeralKey: this.ephemeralKey,
instanceName: this.hostId
})
});
const data = await response.json();
return data as ValidateLicenseAPIResponse;
}
}
await setHostMeta();
@@ -483,6 +50,6 @@ if (!info) {
throw new Error("Host information not found");
}
export const license = new License(info.hostMetaId);
export const license = new License(info);
export default license;

View File

@@ -1,109 +0,0 @@
import * as crypto from "crypto";
/**
* Validates a JWT using a public key
* @param token - The JWT to validate
* @param publicKey - The public key used for verification (PEM format)
* @returns The decoded payload if validation succeeds, throws an error otherwise
*/
function validateJWT<Payload>(
token: string,
publicKey: string
): Payload {
// Split the JWT into its three parts
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("Invalid JWT format");
}
const [encodedHeader, encodedPayload, signature] = parts;
// Decode the header to get the algorithm
const header = JSON.parse(Buffer.from(encodedHeader, "base64").toString());
const algorithm = header.alg;
// Verify the signature
const signatureInput = `${encodedHeader}.${encodedPayload}`;
const isValid = verify(signatureInput, signature, publicKey, algorithm);
if (!isValid) {
throw new Error("Invalid signature");
}
// Decode the payload
const payload = JSON.parse(
Buffer.from(encodedPayload, "base64").toString()
);
// Check if the token has expired
const now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) {
throw new Error("Token has expired");
}
return payload;
}
/**
* Verifies the signature of a JWT
*/
function verify(
input: string,
signature: string,
publicKey: string,
algorithm: string
): boolean {
let verifyAlgorithm: string;
// Map JWT algorithm name to Node.js crypto algorithm name
switch (algorithm) {
case "RS256":
verifyAlgorithm = "RSA-SHA256";
break;
case "RS384":
verifyAlgorithm = "RSA-SHA384";
break;
case "RS512":
verifyAlgorithm = "RSA-SHA512";
break;
case "ES256":
verifyAlgorithm = "SHA256";
break;
case "ES384":
verifyAlgorithm = "SHA384";
break;
case "ES512":
verifyAlgorithm = "SHA512";
break;
default:
throw new Error(`Unsupported algorithm: ${algorithm}`);
}
// Convert base64url signature to standard base64
const base64Signature = base64URLToBase64(signature);
// Verify the signature
const verifier = crypto.createVerify(verifyAlgorithm);
verifier.update(input);
return verifier.verify(publicKey, base64Signature, "base64");
}
/**
* Converts base64url format to standard base64
*/
function base64URLToBase64(base64url: string): string {
// Add padding if needed
let base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
const pad = base64.length % 4;
if (pad) {
if (pad === 1) {
throw new Error("Invalid base64url string");
}
base64 += "=".repeat(4 - pad);
}
return base64;
}
export { validateJWT };