263 lines
8.2 KiB
JavaScript
263 lines
8.2 KiB
JavaScript
import crypto from 'crypto';
|
|
import { IPreset } from './defs';
|
|
import {
|
|
AdvancedBuffer,
|
|
HKDF,
|
|
getRandomChunks,
|
|
numberToBuffer,
|
|
incrementLE,
|
|
} from '../utils';
|
|
|
|
const NONCE_LEN = 12;
|
|
const TAG_LEN = 16;
|
|
const MIN_CHUNK_LEN = TAG_LEN * 2 + 3;
|
|
const MIN_CHUNK_SPLIT_LEN = 0x0800;
|
|
const MAX_CHUNK_SPLIT_LEN = 0x3FFF;
|
|
const DEFAULT_INFO = 'bs-subkey';
|
|
const DEFAULT_FACTOR = 2;
|
|
|
|
// available ciphers
|
|
const ciphers = {
|
|
'aes-128-gcm': 16,
|
|
'aes-192-gcm': 24,
|
|
'aes-256-gcm': 32,
|
|
};
|
|
|
|
const HKDF_HASH_ALGORITHM = 'sha1';
|
|
|
|
/**
|
|
* @description
|
|
* An advanced aead cipher based on ss-aead-cipher, with random padding.
|
|
*
|
|
* @params
|
|
* method: The encryption/decryption method.
|
|
* info(optional): An info for HKDF.
|
|
* factor(optional): Expand random padding length(0-255) by factor times. It must be in [1, 10]. Default is 2.
|
|
*
|
|
* @examples
|
|
* {
|
|
* "name": "aead-random-cipher",
|
|
* "params": {
|
|
* "method": "aes-128-gcm",
|
|
* "info": "bs-subkey",
|
|
* "factor": 2
|
|
* }
|
|
* }
|
|
*
|
|
* @protocol
|
|
*
|
|
* # TCP stream
|
|
* +---------+------------+------------+-----------+
|
|
* | SALT | chunk_0 | chunk_1 | ... |
|
|
* +---------+------------+------------+-----------+
|
|
* | Fixed | Variable | Variable | ... |
|
|
* +---------+------------+------------+-----------+
|
|
*
|
|
* # TCP chunk
|
|
* +----------------+---------+-------------+----------------+--------------+
|
|
* | Random Padding | DataLen | DataLen_TAG | Data | Data_TAG |
|
|
* +----------------+---------+-------------+----------------+--------------+
|
|
* | Variable | 2 | Fixed | Variable | Fixed |
|
|
* +----------------+---------+-------------+----------------+--------------+
|
|
*
|
|
* @explain
|
|
* 1. Salt is randomly generated, and is to derive the per-session subkey in HKDF.
|
|
* 2. DataLen and Data are ciphertext, while TAGs are plaintext.
|
|
* 3. TAGs are automatically generated and verified by Node.js crypto module.
|
|
* 4. len(Data) <= 0x3FFF.
|
|
* 5. The high 2-bit of DataLen must be zero.
|
|
* 6. Nonce is used as IV in encryption/decryption.
|
|
* 7. Nonce is little-endian.
|
|
* 8. Each chunk increases the nonce twice.
|
|
* 9. len(Random Padding) = encrypt(key, nonce).tag[0] * factor.
|
|
* 10. factor is used for expanding length of padding.
|
|
*/
|
|
export default class AeadRandomCipherPreset extends IPreset {
|
|
|
|
_cipherName = '';
|
|
|
|
_info = null;
|
|
|
|
_factor = DEFAULT_FACTOR;
|
|
|
|
_rawKey = null;
|
|
|
|
_keySaltSize = 0; // key and salt size
|
|
|
|
_cipherKey = null;
|
|
|
|
_decipherKey = null;
|
|
|
|
_cipherNonce = null;
|
|
|
|
_decipherNonce = null;
|
|
|
|
// sorry for bad naming,
|
|
// this is used for determining if the current chunk had dropped random padding.
|
|
// please check out onReceiving()
|
|
_nextExpectDecipherNonce = null;
|
|
|
|
_adBuf = null;
|
|
|
|
static onCheckParams({ method, info = DEFAULT_INFO, factor = DEFAULT_FACTOR }) {
|
|
if (method === undefined || method === '') {
|
|
throw Error('\'method\' must be set');
|
|
}
|
|
const cipherNames = Object.keys(ciphers);
|
|
if (!cipherNames.includes(method)) {
|
|
throw Error(`'method' must be one of [${cipherNames}]`);
|
|
}
|
|
if (typeof info !== 'string' || info.length <= 0) {
|
|
throw Error('\'info\' must be a non-empty string');
|
|
}
|
|
if (!Number.isInteger(factor)) {
|
|
throw Error('\'factor\' must be an integer');
|
|
}
|
|
if (factor < 1 || factor > 10) {
|
|
throw Error('\'factor\' must be in [1, 10]');
|
|
}
|
|
}
|
|
|
|
onInit({ method, info = DEFAULT_INFO, factor = DEFAULT_FACTOR }) {
|
|
this._cipherName = method;
|
|
this._info = Buffer.from(info);
|
|
this._factor = factor;
|
|
this._rawKey = Buffer.from(this._config.key);
|
|
this._keySaltSize = ciphers[method];
|
|
this._adBuf = new AdvancedBuffer({ getPacketLength: this.onReceiving.bind(this) });
|
|
this._adBuf.on('data', this.onChunkReceived.bind(this));
|
|
this._cipherNonce = Buffer.alloc(NONCE_LEN);
|
|
this._decipherNonce = Buffer.alloc(NONCE_LEN);
|
|
this._nextExpectDecipherNonce = Buffer.alloc(NONCE_LEN);
|
|
}
|
|
|
|
onDestroy() {
|
|
this._adBuf.clear();
|
|
this._adBuf = null;
|
|
this._cipherKey = null;
|
|
this._decipherKey = null;
|
|
this._cipherNonce = null;
|
|
this._decipherNonce = null;
|
|
this._nextExpectDecipherNonce = null;
|
|
}
|
|
|
|
beforeOut({ buffer }) {
|
|
let salt = null;
|
|
if (this._cipherKey === null) {
|
|
const size = this._keySaltSize;
|
|
salt = crypto.randomBytes(size);
|
|
this._cipherKey = HKDF(HKDF_HASH_ALGORITHM, salt, this._rawKey, this._info, size);
|
|
}
|
|
const chunks = getRandomChunks(buffer, MIN_CHUNK_SPLIT_LEN, MAX_CHUNK_SPLIT_LEN).map((chunk) => {
|
|
// random padding
|
|
const paddingLen = this.getPaddingLength(this._cipherKey, this._cipherNonce);
|
|
const padding = crypto.randomBytes(paddingLen);
|
|
// encLen, lenTag
|
|
const dataLen = numberToBuffer(chunk.length);
|
|
const [encLen, lenTag] = this.encrypt(dataLen);
|
|
// encData, dataTag
|
|
const [encData, dataTag] = this.encrypt(chunk);
|
|
return Buffer.concat([padding, encLen, lenTag, encData, dataTag]);
|
|
});
|
|
if (salt) {
|
|
return Buffer.concat([salt, ...chunks]);
|
|
} else {
|
|
return Buffer.concat(chunks);
|
|
}
|
|
}
|
|
|
|
beforeIn({ buffer, next, fail }) {
|
|
this._adBuf.put(buffer, { next, fail });
|
|
}
|
|
|
|
onReceiving(buffer, { fail }) {
|
|
// 1. init this._decipherKey
|
|
if (this._decipherKey === null) {
|
|
const size = this._keySaltSize;
|
|
if (buffer.length < size) {
|
|
return; // too short to get salt
|
|
}
|
|
const salt = buffer.slice(0, size);
|
|
this._decipherKey = HKDF(HKDF_HASH_ALGORITHM, salt, this._rawKey, this._info, size);
|
|
return buffer.slice(size); // drop salt
|
|
}
|
|
|
|
// 2. determine padding length then drop it
|
|
if (this._decipherNonce.equals(this._nextExpectDecipherNonce)) {
|
|
const paddingLen = this.getPaddingLength(this._decipherKey, this._decipherNonce);
|
|
if (buffer.length < paddingLen) {
|
|
return; // too short to drop padding
|
|
}
|
|
// because each chunk increases the nonce twice
|
|
incrementLE(this._nextExpectDecipherNonce);
|
|
incrementLE(this._nextExpectDecipherNonce);
|
|
return buffer.slice(paddingLen); // drop random padding
|
|
}
|
|
|
|
if (buffer.length < MIN_CHUNK_LEN) {
|
|
return; // too short to verify DataLen
|
|
}
|
|
|
|
// 3. verify DataLen, DataLen_TAG
|
|
const [encLen, lenTag] = [buffer.slice(0, 2), buffer.slice(2, 2 + TAG_LEN)];
|
|
const dataLenBuf = this.decrypt(encLen, lenTag);
|
|
if (dataLenBuf === null) {
|
|
fail(`unexpected DataLen_TAG=${lenTag.toString('hex')} when verify DataLen=${encLen.toString('hex')}, dump=${buffer.slice(0, 60).toString('hex')}`);
|
|
return -1;
|
|
}
|
|
const dataLen = dataLenBuf.readUInt16BE(0);
|
|
if (dataLen > MAX_CHUNK_SPLIT_LEN) {
|
|
fail(`invalid DataLen=${dataLen} is over ${MAX_CHUNK_SPLIT_LEN}, dump=${buffer.slice(0, 60).toString('hex')}`);
|
|
return -1;
|
|
}
|
|
return 2 + TAG_LEN + dataLen + TAG_LEN;
|
|
}
|
|
|
|
onChunkReceived(chunk, { next, fail }) {
|
|
// verify Data, Data_TAG
|
|
const [encData, dataTag] = [chunk.slice(2 + TAG_LEN, -TAG_LEN), chunk.slice(-TAG_LEN)];
|
|
const data = this.decrypt(encData, dataTag);
|
|
if (data === null) {
|
|
fail(`unexpected Data_TAG=${dataTag.toString('hex')} when verify Data=${encData.slice(0, 60).toString('hex')}, dump=${chunk.slice(0, 60).toString('hex')}`);
|
|
return;
|
|
}
|
|
next(data);
|
|
}
|
|
|
|
getPaddingLength(key, nonce) {
|
|
const cipher = crypto.createCipheriv(this._cipherName, key, nonce);
|
|
cipher.update(nonce);
|
|
cipher.final();
|
|
return cipher.getAuthTag()[0] * this._factor;
|
|
}
|
|
|
|
encrypt(message) {
|
|
const cipher = crypto.createCipheriv(
|
|
this._cipherName,
|
|
this._cipherKey,
|
|
this._cipherNonce,
|
|
);
|
|
const encrypted = Buffer.concat([cipher.update(message), cipher.final()]);
|
|
const tag = cipher.getAuthTag();
|
|
incrementLE(this._cipherNonce);
|
|
return [encrypted, tag];
|
|
}
|
|
|
|
decrypt(ciphertext, tag) {
|
|
const decipher = crypto.createDecipheriv(
|
|
this._cipherName,
|
|
this._decipherKey,
|
|
this._decipherNonce,
|
|
);
|
|
decipher.setAuthTag(tag);
|
|
try {
|
|
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
incrementLE(this._decipherNonce);
|
|
return decrypted;
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
}
|