mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-22 08:47:10 +08:00
339 lines
8.4 KiB
TypeScript
339 lines
8.4 KiB
TypeScript
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;
|
|
}
|
|
}
|