import { createCipheriv, createDecipheriv, createHash, createPrivateKey, createPublicKey, createSign, createVerify, generateKeyPairSync, type KeyObject, randomBytes, randomInt, sign, timingSafeEqual, verify, } from 'node:crypto'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { hash as hashPassword, verify as verifyPassword, } from '@node-rs/argon2'; import { AFFINE_PRO_LICENSE_AES_KEY, AFFINE_PRO_PUBLIC_KEY, } from '../../native'; import { Config } from '../config'; import { OnEvent } from '../event'; const NONCE_LENGTH = 12; const AUTH_TAG_LENGTH = 12; function generatePrivateKey(): string { const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1', }); // Export EC private key as PKCS#8 PEM. This avoids OpenSSL 3.x decoder issues // in Node.js 22 when later deriving the public key via createPublicKey. const key = privateKey.export({ type: 'pkcs8', format: 'pem', }); return key.toString('utf8'); } function parseKey(privateKey: string) { const keyBuf = Buffer.from(privateKey); let priv: KeyObject; try { priv = createPrivateKey({ key: keyBuf, format: 'pem', type: 'pkcs8' }); } catch (e1) { try { priv = createPrivateKey({ key: keyBuf, format: 'pem', type: 'sec1' }); } catch (e2) { // As a last resort rely on auto-detection priv = createPrivateKey(keyBuf); } } const pub = createPublicKey(priv); return { priv, pub }; } @Injectable() export class CryptoHelper implements OnModuleInit { logger = new Logger(CryptoHelper.name); keyPair!: { publicKey: KeyObject; privateKey: KeyObject; sha256: { publicKey: Buffer; privateKey: Buffer; }; }; private previousPublicKeys: KeyObject[] = []; AFFiNEProPublicKey: Buffer | null = null; AFFiNEProLicenseAESKey: Buffer | null = null; onModuleInit() { if (env.selfhosted) { this.AFFiNEProPublicKey = this.loadAFFiNEProPublicKey(); this.AFFiNEProLicenseAESKey = this.loadAFFiNEProLicenseAESKey(); } } constructor(private readonly config: Config) {} @OnEvent('config.init') onConfigInit() { this.setup(); } @OnEvent('config.changed') onConfigChanged(event: Events['config.changed']) { if (event.updates.crypto?.privateKey) { this.setup(); } } private setup() { const prevPublicKey = this.keyPair?.publicKey; const privateKey = this.config.crypto.privateKey || generatePrivateKey(); const { priv, pub } = parseKey(privateKey); const publicKey = pub .export({ format: 'pem', type: 'spki' }) .toString('utf8'); if (prevPublicKey) { const prevPem = prevPublicKey .export({ format: 'pem', type: 'spki' }) .toString('utf8'); if (prevPem !== publicKey) { this.previousPublicKeys.unshift(prevPublicKey); this.previousPublicKeys = this.previousPublicKeys.slice(0, 2); } } this.keyPair = { publicKey: pub, privateKey: priv, sha256: { publicKey: this.sha256(publicKey), privateKey: this.sha256(privateKey), }, }; } private get keyType() { return (this.keyPair.privateKey.asymmetricKeyType as string) || 'ec'; } sign(data: string) { const input = Buffer.from(data, 'utf-8'); if (this.keyType === 'ed25519') { // Ed25519 signs the message directly (no pre-hash) const sig = sign(null, input, this.keyPair.privateKey); return `${data},${sig.toString('base64')}`; } else { // ECDSA with SHA-256 for EC keys const sign = createSign('sha256'); sign.update(input); sign.end(); return `${data},${sign.sign(this.keyPair.privateKey, 'base64')}`; } } verify(signatureWithData: string) { const [data, signature] = signatureWithData.split(','); if (!signature) { return false; } const input = Buffer.from(data, 'utf-8'); const sigBuf = Buffer.from(signature, 'base64'); const keys = [this.keyPair.publicKey, ...this.previousPublicKeys]; return keys.some(publicKey => { const keyType = (publicKey.asymmetricKeyType as string) || 'ec'; if (keyType === 'ed25519') { // Ed25519 verifies the message directly return verify(null, input, publicKey, sigBuf); } else { // ECDSA with SHA-256 const verifier = createVerify('sha256'); verifier.update(input); verifier.end(); return verifier.verify(publicKey, sigBuf); } }); } signInternalAccessToken(input: { method: string; path: string; now?: number; nonce?: string; }) { const payload = { v: 1 as const, ts: input.now ?? Date.now(), nonce: input.nonce ?? this.randomBytes(16).toString('base64url'), m: input.method.toUpperCase(), p: input.path, }; const data = Buffer.from(JSON.stringify(payload), 'utf8').toString( 'base64url' ); return this.sign(data); } parseInternalAccessToken(signatureWithData: string): { v: 1; ts: number; nonce: string; m: string; p: string; } | null { const [data, signature] = signatureWithData.split(','); if (!signature) { return null; } if (!this.verify(signatureWithData)) { return null; } try { const json = Buffer.from(data, 'base64url').toString('utf8'); const payload = JSON.parse(json) as unknown; if (!payload || typeof payload !== 'object') { return null; } const val = payload as { v?: unknown; ts?: unknown; nonce?: unknown; m?: unknown; p?: unknown; }; if ( val.v !== 1 || typeof val.ts !== 'number' || typeof val.nonce !== 'string' || typeof val.m !== 'string' || typeof val.p !== 'string' ) { return null; } return { v: 1, ts: val.ts, nonce: val.nonce, m: val.m, p: val.p }; } catch { return null; } } encrypt(data: string) { const iv = this.randomBytes(); const cipher = createCipheriv( 'aes-256-gcm', this.keyPair.sha256.privateKey, iv, { authTagLength: AUTH_TAG_LENGTH, } ); const encrypted = Buffer.concat([ cipher.update(data, 'utf-8'), cipher.final(), ]); const authTag = cipher.getAuthTag(); return Buffer.concat([iv, authTag, encrypted]).toString('base64'); } decrypt(encrypted: string) { const buf = Buffer.from(encrypted, 'base64'); const iv = buf.subarray(0, NONCE_LENGTH); const authTag = buf.subarray(NONCE_LENGTH, NONCE_LENGTH + AUTH_TAG_LENGTH); const encryptedToken = buf.subarray(NONCE_LENGTH + AUTH_TAG_LENGTH); const decipher = createDecipheriv( 'aes-256-gcm', this.keyPair.sha256.privateKey, iv, { authTagLength: AUTH_TAG_LENGTH } ); decipher.setAuthTag(authTag); const decrepted = decipher.update(encryptedToken, void 0, 'utf8'); return decrepted + decipher.final('utf8'); } encryptPassword(password: string) { return hashPassword(password); } verifyPassword(password: string, hash: string) { return verifyPassword(hash, password); } compare(lhs: string, rhs: string) { if (lhs.length !== rhs.length) { return false; } return timingSafeEqual(Buffer.from(lhs), Buffer.from(rhs)); } randomBytes(length = NONCE_LENGTH) { return randomBytes(length); } randomInt(min: number, max: number) { return randomInt(min, max); } otp(length = 6) { let otp = ''; for (let i = 0; i < length; i++) { otp += this.randomInt(0, 10).toString(); } return otp; } sha256(data: string) { return createHash('sha256').update(data).digest(); } private loadAFFiNEProPublicKey() { if (AFFINE_PRO_PUBLIC_KEY) { return Buffer.from(AFFINE_PRO_PUBLIC_KEY); } else { this.logger.warn('AFFINE_PRO_PUBLIC_KEY is not set at compile time.'); } if (!env.prod && process.env.AFFiNE_PRO_PUBLIC_KEY) { return Buffer.from(process.env.AFFiNE_PRO_PUBLIC_KEY); } 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; } }