From dc7cd0487bc25c58ecef4e4853fcae3c23fc27ad Mon Sep 17 00:00:00 2001 From: forehalo Date: Tue, 27 May 2025 11:54:28 +0000 Subject: [PATCH] refactor(server): decrypt license with provided aes key (#12570) ## Summary by CodeRabbit - **New Features** - Added support for a new AES key for license management, improving license encryption and decryption processes. - **Bug Fixes** - Improved error messages and handling when activating expired or invalid licenses. - **Refactor** - Updated license decryption logic to use a fixed AES key instead of deriving one from the workspace ID. - Added validation for environment variable values to prevent invalid configurations. - **Tests** - Enhanced license-related tests to cover new key usage and updated error messages. - Updated environment variable validation tests with clearer error messages. - **Chores** - Updated environment variable handling for improved consistency. - Set production environment variable explicitly in build configuration. --- .github/workflows/build-images.yml | 1 + packages/backend/native/index.d.ts | 2 + packages/backend/native/src/lib.rs | 6 ++- .../__fixtures__/expired-end-at.license | Bin 592 -> 587 bytes .../e2e/license/__fixtures__/expired.license | Bin 592 -> 587 bytes .../e2e/license/__fixtures__/valid.license | Bin 594 -> 587 bytes .../__tests__/e2e/license/resolver.spec.ts | 9 +++-- .../backend/server/src/__tests__/env.spec.ts | 2 +- .../backend/server/src/base/helpers/crypto.ts | 23 +++++++++++- packages/backend/server/src/env.ts | 10 ++++- packages/backend/server/src/native.ts | 2 + .../server/src/plugins/license/service.ts | 35 ++++++++++++------ tools/cli/src/webpack/index.ts | 3 ++ 13 files changed, 74 insertions(+), 19 deletions(-) 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 3690e051552a036650ddb97bf811b2b87601c59a..e9fa1253f665caf4b75676316bf495c0c3d16f94 100644 GIT binary patch literal 587 zcmV-R0<`@M48CAolnqzb^7S=)`;lNSUd}=YJXv-crmo)nj+=IK>CMkZ49Q+_vXfa% zQDxR73e#|BrV*B1f7>9=YN+q^N{8(Vw`7+eLKc@-Gc(I6fa|-Qfg7rzh3@a|&rFtk zpfk03iUkvFieUSVG&Gi^uzM{6%^0HY1QTvEp$NXCyH-^w9I$`j^v8!u8ym6X+I?rf z@5)IoZd&m8oX;_jaj=dRCJ7rHXpX`sa)}{2vhTl3z6Qns89?66jLzPjW~2_TNf+~f zII?OqZ5?+TY=0wboiAy%uceC+XygqYK(>?pMqle>9(2mHY-W;D55Le25u!=)He=>i zhaflJqXojIORFC7T{16z_*6>(`RP^EtpoCus=qt!*18RDA32+81NcTtIqoHE!+-4| zf>0~>rREZvgl-jPvT9x}Bi<`4#39#hkMughuaz8kI%ymdx{S0>9)DxCf> z*cZj)T&ZyYTSRJB{df(K2j@-$XgsX|n*h~sU#wWZI+%@I3NnO1OuMw^vjD|sEst%Z zI%S73E4M3I-^*k76BL(PAR=#Isgk*(e0C6e(CK2=~)*mHbe zdl@D{OyNG-{hj_vfE~;F3GDhAdZ?cp2 zIG79#TiQDfaQNmaMJ4a|`d9Y)bFv0?;1y8r&=ELNo`XgEeAgHXdZBtCv6dXUh*ncnS zJpsmj*48>K)~q~XX~HTrBL>tC>ouVDO0%UrDz2COR1%J0NJR3IV$hUuoD+^TUPT)1 z+zKgxN|LbKvE&lcF5?y;1(nj<;d{9>(`V@l2sM=9+XWwj(R4e5^snAmrS zUUnPgs*MFuK^D74I2y*16wl9kiNnzX9G{Di)TiXz80Y~9_lAMaay`&?h6rY3CCPbg z;|ag{wuflRPZs+sKqw?TeAq9DR{nT4`v2hfxW(IS*m+~A!)ci!G!Q2hI9!jl@r$06 ecCIP;&nP(0es~MUJW25pgs)w*0Rd8s0>zQjBosFQ 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 3c0d0684883a614fd3ae845e7bc443503499e0bc..336d45b64cb11eb501ebc06314129ed2e0d7f25e 100644 GIT binary patch literal 587 zcmV-R0<`@M41eF=GNsPZz#=qlhDx$sjfrdtLgM-EcW(hubfqGia}UZbdrp9JHPs@_ zZ!Npv3*g?}RSPZZIj*d|nrHg8FEnJDN==KRI2kIm7`E=yEkWIS;ID2hGTpGO_pUaa zjy8~nmw1PWV}EKdkxV9PtBRL4#*AW^mq;`5SY7&`G=@y&`oPXNUz}WUT{hj)(k-4c z?3pjK9sy&#lYmo_kB_>2zaSh0R*6jHtub<_S%eQ@cWoqqdQ48P$EuG*jcKhI%TM_D z9eZ~fHA~56XpE^a6Shx7?c$(9;_gaYU>5W$VZ*{4k<893FC@!it1d>AnW7~@1Wr-Z zhTeX@)dfoFNeVb~oO#h5T&M;D3W3-s>!EHX!1IT2pmCPxWCfMLbn#zz&L^`0IJ-y? zSd8Zct28)KqsJqqmT8R1OUfEDuq%^pmyk}C@%u*|K;FjpxA zq{|7=-2-@h*a*YHWMV-Iz3txSCn=NVJHseFzHu^K=(z0RN)BH@S{?7GAJ48PIZ z_%hT%F=x|745!0G1?KdZ0^4L$k}J?98H6_H%?Udj6J%_@PA>%(hv4Ws#|ZKQycF;? zCn2O4C_o<=ZQaQ(M1|g_Bpb}{GX>ndv$QgbY&+-X>;=WYS;f3=l2i4x%gIZXUF0vZ Zj^s!&2JgLZR;H{e>K0vu{hEC>cu!xfALal6 literal 592 zcmV-W0+K znXztwio!02=i$c}y5j-Q4>sp8yGq6uAtg}rBhqE=!sHGZ}RRk1B0t3QiQ#8asha-isnqP}y8 z(PPg+Cx6%Nws@qkRD!~#)o*Cr-8fSL8Nm$S`;CX6@Kn~n#n48Wz=Ahh3TLJb1l*c+ zCFKCWOb1M8URNGpBP1baS+u0TMD6iVxAlPWU2h#*Y7oCW6_6i>4Y_8HaDzgJ7tTd37Xq8@ z`0{KX9;)Xe`Gzekz??Z-g~Dn^W#Ptp{#%6`uX&rIY|Ds5f)JqNT=^CxkELd}d%zv? eCA)!DbLhflf_KN>x(I+9kNmQ4PB1n-Z>91=8zX`M 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 84943b133c15b0dda469e962054e13a276a3231b..bbe3ed2a02c8895e3ba684b3e3655b3002d01e5f 100644 GIT binary patch literal 587 zcmV-R0<`@M3}8EOvvH&T;KVlWAkdN|0kl-MCRof7VQA8xeXeI_E*o)+W!Ij|RR*Zk zHv!MLDwHj*?du?vy4UecJjTWz3wRqK)Q0q0tiQ{#WHL_)cPv^$6(Z{zT$NKmGvcOn zq_E3%Lg1ocVmZ85H9*JIZpOv1aIbgj?eS*Zs!XW$K(^-O9)m&RZ-b>nXs!~E21`a_ zl{d?Ft!rxtwd!Up=Y_+3@?@Dhh zc`*9wCl7+Z*79*p)lNY|i$Z5!*~KITGK4&P|CmbRn!P*J<4g)E1z7yo8TJfDX=qt2 zE`pTNi*FB`M&k$EJ=UQz8V403UslUw2Y=Eq!Jfy(lrWAr<%+Fw_%0wYb{v?rdnJMx zPzt|#lJSV}0%s7Fs9~F977>us<|^5iL(m<{+TF%kqG{trdbU4-RJlc-)A1#r=2tWt zp&E500~^uj#>>?GUTIP_`2)@c;1_++@@vJWMNG+ap%x#)w2ZY#&xhEZ`SIR8{FsYm+>(>t{F_Br6Of)4qcfDD)W0l2SoTB`LH!97L21X)Mub(v{F> zqvSywVL6zdYrF)CiK!(C1k9!KUE}S5k!9yRm=zGob^<}ElhmDJbu=$i^=`aD?Fylo z^`NFjYVqup<%c#y_Y7@ctCPY`tHv@znPTmyO}E0UJ-F**sVkv{@eHo_j68b055t!4CsFkGeE%jD7#*jPRe zM6wk34n^3-+FvcO(52tY3`k120ba3E_q}7E<&?ccnpTH$3^kWgyA?&-LEFi=y*(51 zcf#^B(T>%Fd^B7KA&n0K+?i&}0+b2g3tSMGPk*(*%s2t56~;OT!`+^CW&sAyiJDs$ zizij~wFDc!eQd`gQ*F^As0-++MvbY9R3A#Ju9ZVfZ}6VPh?c?_YdDbqEk$xkcru9P g5K)#Q_qvSxT4KLYmvYZDV-ae9&;Gc*h~wp#Y~U^_z5oCK 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 },