diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index c8fbad4ce6..fed00af18e 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -138,6 +138,7 @@ jobs: uses: ./.github/actions/build-rust env: AFFINE_PRO_PUBLIC_KEY: ${{ secrets.AFFINE_PRO_PUBLIC_KEY }} + AFFINE_PRO_LICENSE_AES_KEY: ${{ secrets.AFFINE_PRO_LICENSE_AES_KEY }} with: target: ${{ matrix.targets.name }} package: '@affine/server-native' diff --git a/packages/backend/native/index.d.ts b/packages/backend/native/index.d.ts index 2263bf3fbd..8456170f3c 100644 --- a/packages/backend/native/index.d.ts +++ b/packages/backend/native/index.d.ts @@ -4,6 +4,8 @@ export declare class Tokenizer { count(content: string, allowedSpecial?: Array | undefined | null): number } +export const AFFINE_PRO_LICENSE_AES_KEY: string | undefined | null + export const AFFINE_PRO_PUBLIC_KEY: string | undefined | null export declare function fromModelName(modelName: string): Tokenizer | null diff --git a/packages/backend/native/src/lib.rs b/packages/backend/native/src/lib.rs index c6da005d29..ff27317723 100644 --- a/packages/backend/native/src/lib.rs +++ b/packages/backend/native/src/lib.rs @@ -54,4 +54,8 @@ pub fn merge_updates_in_apply_way(updates: Vec) -> Result { } #[napi] -pub const AFFINE_PRO_PUBLIC_KEY: Option<&'static str> = std::option_env!("AFFINE_PRO_PUBLIC_KEY",); +pub const AFFINE_PRO_PUBLIC_KEY: Option<&'static str> = std::option_env!("AFFINE_PRO_PUBLIC_KEY"); + +#[napi] +pub const AFFINE_PRO_LICENSE_AES_KEY: Option<&'static str> = + std::option_env!("AFFINE_PRO_LICENSE_AES_KEY"); diff --git a/packages/backend/server/src/__tests__/e2e/license/__fixtures__/expired-end-at.license b/packages/backend/server/src/__tests__/e2e/license/__fixtures__/expired-end-at.license index 3690e05155..e9fa1253f6 100644 Binary files a/packages/backend/server/src/__tests__/e2e/license/__fixtures__/expired-end-at.license and b/packages/backend/server/src/__tests__/e2e/license/__fixtures__/expired-end-at.license differ diff --git a/packages/backend/server/src/__tests__/e2e/license/__fixtures__/expired.license b/packages/backend/server/src/__tests__/e2e/license/__fixtures__/expired.license index 3c0d068488..336d45b64c 100644 Binary files a/packages/backend/server/src/__tests__/e2e/license/__fixtures__/expired.license and b/packages/backend/server/src/__tests__/e2e/license/__fixtures__/expired.license differ diff --git a/packages/backend/server/src/__tests__/e2e/license/__fixtures__/valid.license b/packages/backend/server/src/__tests__/e2e/license/__fixtures__/valid.license index 84943b133c..bbe3ed2a02 100644 Binary files a/packages/backend/server/src/__tests__/e2e/license/__fixtures__/valid.license and b/packages/backend/server/src/__tests__/e2e/license/__fixtures__/valid.license differ diff --git a/packages/backend/server/src/__tests__/e2e/license/resolver.spec.ts b/packages/backend/server/src/__tests__/e2e/license/resolver.spec.ts index 1627619850..566e7c40b7 100644 --- a/packages/backend/server/src/__tests__/e2e/license/resolver.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/license/resolver.spec.ts @@ -15,9 +15,10 @@ import { const testWorkspaceId = 'd6f52bc7-d62a-4822-804a-335fa7dfe5a6'; const testPublicKey = `-----BEGIN PUBLIC KEY----- -MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkXqe9HR32rSgaNxaePXbwHxoanUq -1DcQTV1twn+G47/HiY+rj7oOw3cLNzOVe7+4Uxn8SMZ/XLImtqFQ6I4WVg== +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqrxlczPknUuj4q4xx1VGr063Cgu7 +Hc3w7v4FGmoA5MNzzhrkho1ckDYw2wrX6zBnehFzcivURv80HherE2GQjg== -----END PUBLIC KEY-----`; +const testTestLicenseAESKey = 'TEST_LICENSE_AES_KEY'; const fixturesDir = join(import.meta.dirname, '__fixtures__'); function getLicense(file: string) { @@ -39,6 +40,7 @@ let owner: MockedUser; e2e.before(async () => { process.env.DEPLOYMENT_TYPE = 'selfhosted'; process.env.AFFiNE_PRO_PUBLIC_KEY = testPublicKey; + process.env.AFFiNE_PRO_LICENSE_AES_KEY = testTestLicenseAESKey; refreshEnv(); app = await createApp(); @@ -123,7 +125,8 @@ e2e('should not install expired license', async t => { }, }), { - message: 'License has expired.', + message: + 'Invalid license to activate. License file has expired. Please contact with Affine support to fetch a latest one.', } ); }); diff --git a/packages/backend/server/src/__tests__/env.spec.ts b/packages/backend/server/src/__tests__/env.spec.ts index 970b464fd0..9ad2d14575 100644 --- a/packages/backend/server/src/__tests__/env.spec.ts +++ b/packages/backend/server/src/__tests__/env.spec.ts @@ -29,7 +29,7 @@ test('should read NODE_ENV', t => { }, { message: - 'Invalid value "unknown" for environment variable NODE_ENV, expected one of ["development","test","production"]', + 'Invalid NODE_ENV environment. `unknown` is not a valid NODE_ENV value.', } ); }); diff --git a/packages/backend/server/src/base/helpers/crypto.ts b/packages/backend/server/src/base/helpers/crypto.ts index b8408b1033..8bb271964b 100644 --- a/packages/backend/server/src/base/helpers/crypto.ts +++ b/packages/backend/server/src/base/helpers/crypto.ts @@ -17,7 +17,10 @@ import { verify as verifyPassword, } from '@node-rs/argon2'; -import { AFFINE_PRO_PUBLIC_KEY } from '../../native'; +import { + AFFINE_PRO_LICENSE_AES_KEY, + AFFINE_PRO_PUBLIC_KEY, +} from '../../native'; import { Config } from '../config'; import { OnEvent } from '../event'; @@ -59,10 +62,12 @@ export class CryptoHelper implements OnModuleInit { }; AFFiNEProPublicKey: Buffer | null = null; + AFFiNEProLicenseAESKey: Buffer | null = null; onModuleInit() { if (env.selfhosted) { this.AFFiNEProPublicKey = this.loadAFFiNEProPublicKey(); + this.AFFiNEProLicenseAESKey = this.loadAFFiNEProLicenseAESKey(); } } @@ -197,4 +202,20 @@ export class CryptoHelper implements OnModuleInit { return null; } + + private loadAFFiNEProLicenseAESKey() { + if (AFFINE_PRO_LICENSE_AES_KEY) { + return this.sha256(AFFINE_PRO_LICENSE_AES_KEY); + } else { + this.logger.warn( + 'AFFINE_PRO_LICENSE_AES_KEY is not set at compile time.' + ); + } + + if (!env.prod && process.env.AFFiNE_PRO_LICENSE_AES_KEY) { + return this.sha256(process.env.AFFiNE_PRO_LICENSE_AES_KEY); + } + + return null; + } } diff --git a/packages/backend/server/src/env.ts b/packages/backend/server/src/env.ts index 0d55b85e77..d82236cee6 100644 --- a/packages/backend/server/src/env.ts +++ b/packages/backend/server/src/env.ts @@ -76,7 +76,7 @@ globalThis.readEnv = function readEnv( }; export class Env implements AppEnv { - NODE_ENV = readEnv('NODE_ENV', NodeEnv.Production, Object.values(NodeEnv)); + NODE_ENV = (process.env.NODE_ENV ?? NodeEnv.Production) as NodeEnv; NAMESPACE = readEnv( 'AFFINE_ENV', Namespace.Production, @@ -134,6 +134,14 @@ export class Env implements AppEnv { get gcp() { return this.platform === Platform.GCP; } + + constructor() { + if (!Object.values(NodeEnv).includes(this.NODE_ENV)) { + throw new Error( + `Invalid NODE_ENV environment. \`${this.NODE_ENV}\` is not a valid NODE_ENV value.` + ); + } + } } export const createGlobalEnv = () => { diff --git a/packages/backend/server/src/native.ts b/packages/backend/server/src/native.ts index d47bd0df7e..7b3a451fdf 100644 --- a/packages/backend/server/src/native.ts +++ b/packages/backend/server/src/native.ts @@ -22,3 +22,5 @@ export const Tokenizer = serverNativeModule.Tokenizer; export const fromModelName = serverNativeModule.fromModelName; export const htmlSanitize = serverNativeModule.htmlSanitize; export const AFFINE_PRO_PUBLIC_KEY = serverNativeModule.AFFINE_PRO_PUBLIC_KEY; +export const AFFINE_PRO_LICENSE_AES_KEY = + serverNativeModule.AFFINE_PRO_LICENSE_AES_KEY; diff --git a/packages/backend/server/src/plugins/license/service.ts b/packages/backend/server/src/plugins/license/service.ts index d02b29959a..b8a9d2d559 100644 --- a/packages/backend/server/src/plugins/license/service.ts +++ b/packages/backend/server/src/plugins/license/service.ts @@ -485,11 +485,7 @@ export class LicenseService { // we use workspace id as aes key hash plain text content // verify signature to make sure the payload or signature is not forged - const { - payload: payloadStr, - signature, - iv, - } = this.decryptLicense(workspaceId, buf); + const { payload: payloadStr, signature, iv } = this.decryptLicense(buf); const verifier = createVerify('rsa-sha256'); verifier.update(iv); @@ -515,6 +511,13 @@ export class LicenseService { }); } + if (new Date(parseResult.data.expiresAt) < new Date()) { + throw new InvalidLicenseToActivate({ + reason: + 'License file has expired. Please contact with Affine support to fetch a latest one.', + }); + } + if (parseResult.data.data.workspaceId !== workspaceId) { throw new InvalidLicenseToActivate({ reason: 'Workspace mismatched with license.', @@ -524,7 +527,13 @@ export class LicenseService { return parseResult.data; } - private decryptLicense(workspaceId: string, buf: Buffer) { + private decryptLicense(buf: Buffer) { + if (!this.crypto.AFFiNEProLicenseAESKey) { + throw new InternalServerError( + 'License AES key is not loaded. Please contact with Affine support.' + ); + } + if (buf.length < 2) { throw new InvalidLicenseToActivate({ reason: 'Invalid license file.', @@ -539,12 +548,14 @@ export class LicenseService { const tag = buf.subarray(2 + ivLength, 2 + ivLength + authTagLength); const payload = buf.subarray(2 + ivLength + authTagLength); - const aesKey = this.crypto.sha256( - `WORKSPACE_PAYLOAD_AES_KEY:${workspaceId}` + const decipher = createDecipheriv( + 'aes-256-gcm', + this.crypto.AFFiNEProLicenseAESKey, + iv, + { + authTagLength, + } ); - const decipher = createDecipheriv('aes-256-gcm', aesKey, iv, { - authTagLength, - }); decipher.setAuthTag(tag); const decrypted = Buffer.concat([ @@ -564,7 +575,7 @@ export class LicenseService { } catch { // we use workspace id as aes key hash plain text content throw new InvalidLicenseToActivate({ - reason: 'Workspace mismatched with license.', + reason: 'Failed to verify the license.', }); } } diff --git a/tools/cli/src/webpack/index.ts b/tools/cli/src/webpack/index.ts index 5f45c6a0f4..05028b0de2 100644 --- a/tools/cli/src/webpack/index.ts +++ b/tools/cli/src/webpack/index.ts @@ -601,6 +601,9 @@ export function createNodeTargetConfig( ); }, }), + new webpack.DefinePlugin({ + 'process.env.NODE_ENV': '"production"', + }), ]), stats: { errorDetails: true }, optimization: { nodeEnv: false },