blinksocks/src/presets/ss-aead-cipher.js

336 lines
11 KiB
JavaScript

import crypto from 'crypto';
import semver from 'semver';
import { IPreset } from './defs';
import {
AdvancedBuffer,
dumpHex,
EVP_BytesToKey,
HKDF,
getRandomChunks,
numberToBuffer,
incrementLE,
} from '../utils';
const TAG_SIZE = 16;
const MIN_CHUNK_LEN = TAG_SIZE * 2 + 3;
const MIN_CHUNK_SPLIT_LEN = 0x0800;
const MAX_CHUNK_SPLIT_LEN = 0x3FFF;
// available ciphers and [key size, salt size, nonce size]
const ciphers = {
// from openssl
'aes-128-gcm': [16, 16, 12],
'aes-192-gcm': [24, 24, 12],
'aes-256-gcm': [32, 32, 12],
// from openssl, requires Node.js ^10.2.x
'aes-128-ccm': [16, 16, 12],
'aes-192-ccm': [24, 24, 12],
'aes-256-ccm': [32, 32, 12],
// from libsodium
'chacha20-poly1305': [32, 32, 8],
'chacha20-ietf-poly1305': [32, 32, 12],
'xchacha20-ietf-poly1305': [32, 32, 24],
};
const libsodium_functions = {
'chacha20-poly1305': [
'crypto_aead_chacha20poly1305_encrypt_detached',
'crypto_aead_chacha20poly1305_decrypt_detached',
],
'chacha20-ietf-poly1305': [
'crypto_aead_chacha20poly1305_ietf_encrypt_detached',
'crypto_aead_chacha20poly1305_ietf_decrypt_detached',
],
'xchacha20-ietf-poly1305': [
'crypto_aead_xchacha20poly1305_ietf_encrypt_detached',
'crypto_aead_xchacha20poly1305_ietf_decrypt_detached',
],
};
const DEFAULT_METHOD = 'aes-256-gcm';
const HKDF_HASH_ALGORITHM = 'sha1';
const HKDF_INFO = 'ss-subkey';
let libsodium = null;
/**
* @description
* AEAD ciphers simultaneously provide confidentiality, integrity, and authenticity.
*
* @params
* method: The encryption/decryption method.
*
* @examples
* {
* "name": "ss-aead-cipher",
* "params": {
* "method": "aes-128-gcm"
* }
* }
*
* @protocol
*
* # TCP stream
* +---------+------------+------------+-----------+
* | SALT | chunk_0 | chunk_1 | ... |
* +---------+------------+------------+-----------+
* | Fixed | Variable | Variable | ... |
* +---------+------------+------------+-----------+
*
* # TCP chunk_i
* +---------+-------------+----------------+--------------+
* | DataLen | DataLen_TAG | Data | Data_TAG |
* +---------+-------------+----------------+--------------+
* | 2 | Fixed | Variable | Fixed |
* +---------+-------------+----------------+--------------+
*
* # UDP packet
* +---------+----------------+--------------+
* | SALT | Data | Data_TAG |
* +---------+----------------+--------------+
* | Fixed | Variable | Fixed |
* +---------+----------------+--------------+
*
* @explain
* 1. Salt is randomly generated, and is to derive the per-session subkey in HKDF.
* 2. Shadowsocks python reuse OpenSSLCrypto which derive original key by EVP_BytesToKey first.
* 3. DataLen and Data are ciphertext, while TAGs are plaintext.
* 4. TAGs are automatically generated and verified by Node.js crypto module.
* 5. len(Data) <= 0x3FFF.
* 6. The high 2-bit of DataLen must be zero.
* 7. Nonce is used as IV in encryption/decryption.
* 8. Nonce is little-endian.
* 9. Each chunk increases the nonce twice.
*
* @reference
* [1] HKDF
* https://tools.ietf.org/html/rfc5869
* [2] AEAD Spec
* https://shadowsocks.org/en/spec/AEAD-Ciphers.html
* [3] Tags
* https://nodejs.org/dist/latest-v6.x/docs/api/crypto.html#crypto_cipher_getauthtag
* https://nodejs.org/dist/latest-v6.x/docs/api/crypto.html#crypto_decipher_setauthtag_buffer
*/
export default class SsAeadCipherPreset extends IPreset {
_cipherName = '';
_info = Buffer.from(HKDF_INFO);
_keySize = 0;
_saltSize = 0;
_nonceSize = 0;
_evpKey = null;
_isUseLibSodium = false;
_cipherKey = null;
_decipherKey = null;
_cipherNonce = null;
_decipherNonce = null;
_adBuf = null;
static onCheckParams({ method = DEFAULT_METHOD }) {
const cipherNames = Object.keys(ciphers);
if (!cipherNames.includes(method)) {
throw Error(`"method" must be one of ${cipherNames}, but got "${method}"`);
}
if (method.endsWith('ccm') && !semver.gte(process.versions.node, '10.2.0')) {
throw Error('CCM mode requires Node.js >= v10.2.0');
}
}
static async onCache() {
// libsodium-wrappers need to be loaded asynchronously
// so we must wait for it ready before run our service.
// Ref: https://github.com/jedisct1/libsodium.js#usage-as-a-module
const _sodium = require('libsodium-wrappers');
await _sodium.ready;
if (!libsodium) {
libsodium = _sodium;
}
}
onInit({ method = DEFAULT_METHOD }) {
const [keySize, saltSize, nonceSize] = ciphers[method];
this._cipherName = method;
this._keySize = keySize;
this._saltSize = saltSize;
this._nonceSize = nonceSize;
this._evpKey = EVP_BytesToKey(this._config.key, keySize, 16);
this._isUseLibSodium = Object.keys(libsodium_functions).includes(method);
this._adBuf = new AdvancedBuffer({ getPacketLength: this.onReceiving.bind(this) });
this._adBuf.on('data', this.onChunkReceived.bind(this));
this._cipherNonce = Buffer.alloc(nonceSize);
this._decipherNonce = Buffer.alloc(nonceSize);
// TODO: prefer to use openssl in Node.js v10.
// if (this._cipherName === 'chacha20-ietf-poly1305' && semver.gte(process.versions.node, '10.0.0')) {
// this._cipherName = 'chacha20-poly1305';
// this._isUseLibSodium = false;
// }
}
onDestroy() {
this._adBuf.clear();
this._adBuf = null;
this._cipherKey = null;
this._decipherKey = null;
this._cipherNonce = null;
this._decipherNonce = null;
}
// tcp
beforeOut({ buffer }) {
let salt = null;
if (this._cipherKey === null) {
salt = crypto.randomBytes(this._saltSize);
this._cipherKey = HKDF(HKDF_HASH_ALGORITHM, salt, this._evpKey, this._info, this._keySize);
}
const chunks = getRandomChunks(buffer, MIN_CHUNK_SPLIT_LEN, MAX_CHUNK_SPLIT_LEN).map((chunk) => {
const dataLen = numberToBuffer(chunk.length);
const [encLen, lenTag] = this.encrypt(dataLen);
const [encData, dataTag] = this.encrypt(chunk);
return Buffer.concat([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 }) {
if (this._decipherKey === null) {
const saltSize = this._saltSize;
if (buffer.length < saltSize) {
return; // too short to get salt
}
const salt = buffer.slice(0, saltSize);
this._decipherKey = HKDF(HKDF_HASH_ALGORITHM, salt, this._evpKey, this._info, this._keySize);
return buffer.slice(saltSize); // drop salt
}
if (buffer.length < MIN_CHUNK_LEN) {
return; // too short to verify DataLen
}
// verify DataLen, DataLen_TAG
const [encLen, lenTag] = [buffer.slice(0, 2), buffer.slice(2, 2 + TAG_SIZE)];
const dataLenBuf = this.decrypt(encLen, lenTag);
if (dataLenBuf === null) {
fail(`unexpected DataLen_TAG=${dumpHex(lenTag)} when verify DataLen=${dumpHex(encLen)}, dump=${dumpHex(buffer)}`);
return -1;
}
const dataLen = dataLenBuf.readUInt16BE(0);
if (dataLen > MAX_CHUNK_SPLIT_LEN) {
fail(`invalid DataLen=${dataLen} is over ${MAX_CHUNK_SPLIT_LEN}, dump=${dumpHex(buffer)}`);
return -1;
}
return 2 + TAG_SIZE + dataLen + TAG_SIZE;
}
onChunkReceived(chunk, { next, fail }) {
// verify Data, Data_TAG
const [encData, dataTag] = [chunk.slice(2 + TAG_SIZE, -TAG_SIZE), chunk.slice(-TAG_SIZE)];
const data = this.decrypt(encData, dataTag);
if (data === null) {
return fail(`unexpected Data_TAG=${dumpHex(dataTag)} when verify Data=${dumpHex(encData)}, dump=${dumpHex(chunk)}`);
}
next(data);
}
encrypt(message) {
const cipherName = this._cipherName;
const cipherKey = this._cipherKey;
const nonce = this._cipherNonce;
let ciphertext = null;
let tag = null;
if (this._isUseLibSodium) {
const noop = Buffer.alloc(0);
// eslint-disable-next-line
const result = libsodium[libsodium_functions[cipherName][0]](
message, noop, noop, nonce, cipherKey,
);
ciphertext = Buffer.from(result.ciphertext);
tag = Buffer.from(result.mac);
} else {
const cipher = crypto.createCipheriv(cipherName, cipherKey, nonce, {
authTagLength: TAG_SIZE,
});
ciphertext = Buffer.concat([cipher.update(message), cipher.final()]);
tag = cipher.getAuthTag();
}
incrementLE(nonce);
return [ciphertext, tag];
}
decrypt(ciphertext, tag) {
const cipherName = this._cipherName;
const decipherKey = this._decipherKey;
const nonce = this._decipherNonce;
if (this._isUseLibSodium) {
const noop = Buffer.alloc(0);
try {
// eslint-disable-next-line
const plaintext = libsodium[libsodium_functions[cipherName][1]](
noop, ciphertext, tag, noop, nonce, decipherKey,
);
incrementLE(nonce);
return Buffer.from(plaintext);
} catch (err) {
return null;
}
} else {
const decipher = crypto.createDecipheriv(cipherName, decipherKey, nonce, {
authTagLength: TAG_SIZE,
});
decipher.setAuthTag(tag);
try {
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
incrementLE(nonce);
return plaintext;
} catch (err) {
return null;
}
}
}
// udp
beforeOutUdp({ buffer }) {
const salt = crypto.randomBytes(this._saltSize);
this._cipherKey = HKDF(HKDF_HASH_ALGORITHM, salt, this._evpKey, this._info, this._keySize);
this._cipherNonce = Buffer.alloc(this._nonceSize);
const [ciphertext, tag] = this.encrypt(buffer);
return Buffer.concat([salt, ciphertext, tag]);
}
beforeInUdp({ buffer, fail }) {
const saltSize = this._saltSize;
if (buffer.length < saltSize) {
return fail(`too short to get salt, len=${buffer.length} dump=${dumpHex(buffer)}`);
}
const salt = buffer.slice(0, saltSize);
this._decipherKey = HKDF(HKDF_HASH_ALGORITHM, salt, this._evpKey, this._info, this._keySize);
this._decipherNonce = Buffer.alloc(this._nonceSize);
if (buffer.length < saltSize + TAG_SIZE + 1) {
return fail(`too short to verify Data, len=${buffer.length} dump=${dumpHex(buffer)}`);
}
const [encData, dataTag] = [buffer.slice(saltSize, -TAG_SIZE), buffer.slice(-TAG_SIZE)];
const data = this.decrypt(encData, dataTag);
if (data === null) {
return fail(`unexpected Data_TAG=${dumpHex(dataTag)} when verify Data=${dumpHex(encData)}, dump=${dumpHex(buffer)}`);
}
return data;
}
}