refactor(server): decrypt license with provided aes key (#12570)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
forehalo
2025-05-27 11:54:28 +00:00
parent 7175019a0a
commit dc7cd0487b
13 changed files with 74 additions and 19 deletions

View File

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

View File

@@ -4,6 +4,8 @@ export declare class Tokenizer {
count(content: string, allowedSpecial?: Array<string> | 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

View File

@@ -54,4 +54,8 @@ pub fn merge_updates_in_apply_way(updates: Vec<Buffer>) -> Result<Buffer> {
}
#[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");

View File

@@ -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.',
}
);
});

View File

@@ -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.',
}
);
});

View File

@@ -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;
}
}

View File

@@ -76,7 +76,7 @@ globalThis.readEnv = function readEnv<T>(
};
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 = () => {

View File

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

View File

@@ -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.',
});
}
}

View File

@@ -601,6 +601,9 @@ export function createNodeTargetConfig(
);
},
}),
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"production"',
}),
]),
stats: { errorDetails: true },
optimization: { nodeEnv: false },