diff --git a/src/presets/index.js b/src/presets/index.js index 9446634..a4c305f 100644 --- a/src/presets/index.js +++ b/src/presets/index.js @@ -10,6 +10,9 @@ import SsBasePreset from './ss-base'; import SsStreamCipherPreset from './ss-stream-cipher'; import SsAeadCipherPreset from './ss-aead-cipher'; +// v2ray +import V2rayVmessPreset from './v2ray-vmess'; + // obfuscator import ObfsRandomPaddingPreset from './obfs-random-padding'; import ObfsHttpPreset from './obfs-http'; @@ -36,6 +39,9 @@ const mapping = { 'ss-stream-cipher': SsStreamCipherPreset, 'ss-aead-cipher': SsAeadCipherPreset, + // v2ray + 'v2ray-vmess': V2rayVmessPreset, + // obfuscator 'obfs-random-padding': ObfsRandomPaddingPreset, 'obfs-http': ObfsHttpPreset, diff --git a/src/presets/v2ray-vmess.js b/src/presets/v2ray-vmess.js new file mode 100644 index 0000000..52e25a4 --- /dev/null +++ b/src/presets/v2ray-vmess.js @@ -0,0 +1,529 @@ +import net from 'net'; +import crypto from 'crypto'; +import ip from 'ip'; +import {MIDDLEWARE_DIRECTION_UPWARD} from '../core/middleware'; +import {IPreset, CONNECT_TO_REMOTE, CONNECTION_WILL_CLOSE} from './defs'; +import { + hmac, + hash, + shake128, + fnv1a, + getRandomInt, + getChunks, + getCurrentTimestampInt, + numberToBuffer as ntb, + AdvancedBuffer +} from '../utils'; + +const ATYP_V4 = 0x01; +const ATYP_DOMAIN = 0x02; +const ATYP_V6 = 0x03; +const TIME_TOLERANCE = 30; + +function getAddrType(host) { + if (net.isIPv4(host)) { + return ATYP_V4; + } + if (net.isIPv6(host)) { + return ATYP_V6; + } + return ATYP_DOMAIN; +} + +const SECURITY_TYPE_AES_128_GCM = 3; +// const SECURITY_TYPE_CHACHA20_POLY1305 = 4; +const SECURITY_TYPE_NONE = 5; + +const securityTypes = { + 'aes-128-gcm': SECURITY_TYPE_AES_128_GCM, + // 'chacha20-poly1305': SECURITY_TYPE_CHACHA20_POLY1305, + 'none': SECURITY_TYPE_NONE +}; + +/** + * @description + * v2ray vmess protocol implementation. + * + * @params + * id: client uuid. + * security(optional): encryption method, client only, "aes-128-gcm"(default) or "none". + * + * @examples + * { + * "name": "v2ray-vmess", + * "params": { + * "id": "a3482e88-686a-4a58-8126-99c9df64b7bf", + * "security": "aes-128-gcm" + * } + * } + * + * @protocols + * + * # Client Request + * +----------+-------------+---------------+ + * | AuthInfo | Command | Data Chunks | + * +----------+-------------+---------------+ + * | 16 | Variable | Variable | + * +----------+-------------+---------------+ + * + * AuthInfo = HMAC("md5", UUID, UTC) + * + * # Command + * +-----+------+-------+-----+-----+------+------+-----+-----+------+-----+------------+------------+-----+ + * | Ver | IV | Key | V | Opt | PLen | Sec | RSV | Cmd | Port | T | Addr | Padding | F | + * +-----+------+-------+-----+-----+------+------+-----+-----+------+-----+------------+------------+-----+ + * | 1 | 16 | 16 | 1 | 1 | 4bit | 4bit | 1 | 1 | 2 | 1 | Variable | Variable | 4 | + * +-----+------+-------+-----+-----+------+------+-----+-----+------+-----+------------+------------+-----+ + * + * Ver = 0x01 + * IV = rand(16), Key = rand(16), V = rand(1) + * Opt = 0x05 + * 0x01: standard data stream + * 0x02: reuse TCP connection(- deprecated -) + * 0x03: metadata obfuscate + * PLen = 0~15 + * Sec + * 0x00 aes-128-cfb(- deprecated -) + * 0x03 aes-128-gcm + * 0x04 chacha20-poly1305(not support) + * 0x05 none + * RSV = 0x00 + * Cmd = 0x01 + * 0x01 TCP + * 0x02 UDP(not support) + * Port: Big-Endian + * T + * 0x01: ipv4 + * 0x02: hostname + * 0x03: ipv6 + * Addr + * T=0x01: [ip4v], 4 bytes + * T=0x02: [len,hostname] + * T=0x03: [ipv6], 16 bytes + * Padding = rand(PLen) + * F = fnv1a(all the above) + * + * # Data Chunk + * +-----------+------------+ + * | DataLen | Data | + * +-----------+------------+ + * | 2 | Variable | + * +-----------+------------+ + * + * DataLen: Big-Endian, 0 ~ 0x3FFF + * + * # Data(when using "aes-128-gcm") + * +-----------+---------+ + * | Payload | Tag | + * +-----------+---------+ + * | Variable | 16 | + * +-----------+---------+ + * + * # Data(when using "none") + * +-----------+ + * | Payload | + * +-----------+ + * | Variable | + * +-----------+ + * + * # Server Response + * +-----+-----+-----+----------+---------+-------------+ + * | V | Opt | Cmd | CmdLen | CmdData | Data Chunks | + * +-----+-----+-----+----------+---------+-------------+ + * | 1 | 1 | 1 | 1 | Fixed | Variable | + * +-----+-----+-----+----------+---------+-------------+ + * + * V = Request V + * Opt + * 0x01: reuse TCP connection(- deprecated -) + * Cmd + * 0x01: dynamic port(not support) + * Data Chunks: see above + * + * @notes + * 1. "Opt" of client request is always 0x05. + * 2. "alterId" of vmess client must be the default(0). + * 3. "mux" of vmess client must be disabled. + * 4. client UDP relay(CMD = 0x02, typically DNS requests) will be dropped. + * 5. dynamic port is not implemented yet. + * + * @reference + * [1] VMess Spec + * https://www.v2ray.com/chapter_04/03_vmess.html + * [2] VMess Config + * https://www.v2ray.com/chapter_02/protocols/vmess.html + */ +export default class V2rayVmessPreset extends IPreset { + + static uuid = null; + + static security = null; + + static userHashCache = [ + // {timestamp, authInfo} + ]; + + _atyp = null; + _host = null; // buffer + _port = null; // buffer + + _isBroadCasting = false; + _staging = Buffer.alloc(0); + + _isHeaderSent = false; + _isHeaderRecv = false; + + _v = null; + _opt = 0x05; // 0x01(S) or 0x05(S+M) + _dataEncKey = null; + _dataEncIV = null; + _dataDecKey = null; + _dataDecIV = null; + _chunkLenEncMaskGenerator = null; + _chunkLenDecMaskGenerator = null; + + // nonce counter + _cipherNonce = 0; + _decipherNonce = 0; + + static checkParams({id, security = 'aes-128-gcm'}) { + if (Buffer.from(id.split('-').join(''), 'hex').length !== 16) { + throw Error('id is not a valid uuid'); + } + const securities = Object.keys(securityTypes); + if (!securities.includes(security)) { + throw Error(`security must be one of ${securities}`); + } + } + + static onInit({id, security = 'aes-128-gcm'}) { + this.uuid = Buffer.from(id.split('-').join(''), 'hex'); + if (__IS_CLIENT__) { + this.security = securityTypes[security]; + } + setInterval(() => this.updateAuthCache(), 1e3); + this.updateAuthCache(); + } + + static updateAuthCache() { + const items = this.userHashCache; + const now = getCurrentTimestampInt(); + let from = now - TIME_TOLERANCE; + const to = now + TIME_TOLERANCE; + let newItems = []; + if (items.length !== 0) { + const {timestamp: end} = items[items.length - 1]; + newItems = items.slice(now - end - TIME_TOLERANCE - 1); + from = end + 1; + } + for (let ts = from; ts <= to; ++ts) { + // account auth info, 16 bytes + const uuid = this.uuid; + const authInfo = hmac('md5', uuid, ntb(ts, 8)); + newItems.push({timestamp: ts, authInfo: authInfo}); + } + this.userHashCache = newItems; + } + + constructor() { + super(); + this._adBuf = new AdvancedBuffer({getPacketLength: this.onReceiving.bind(this)}); + this._adBuf.on('data', this.onChunkReceived.bind(this)); + } + + onDestroy() { + this._adBuf.clear(); + this._adBuf = null; + this._host = null; + this._port = null; + this._dataEncKey = null; + this._dataEncIV = null; + this._dataDecKey = null; + this._dataDecIV = null; + this._chunkLenEncMaskGenerator = null; + this._chunkLenDecMaskGenerator = null; + } + + onNotified(action) { + if (__IS_CLIENT__ && action.type === CONNECT_TO_REMOTE) { + const {host, port} = action.payload; + const type = getAddrType(host); + this._atyp = type; + this._port = ntb(port); + this._host = (type === ATYP_DOMAIN) ? Buffer.from(host) : ip.toBuffer(host); + } + if (action.type === CONNECTION_WILL_CLOSE) { + this.next(MIDDLEWARE_DIRECTION_UPWARD, Buffer.alloc(2)); + } + } + + beforeOut({buffer}) { + if (!this._isHeaderSent) { + this._isHeaderSent = true; + const header = __IS_CLIENT__ ? this.createRequestHeader() : this.createResponseHeader(); + const chunks = this.getBufferChunks(buffer); + return Buffer.concat([header, ...chunks]); + } else { + const chunks = this.getBufferChunks(buffer); + return Buffer.concat(chunks); + } + } + + clientIn({buffer, next, fail}) { + if (!this._isHeaderRecv) { + this._isHeaderRecv = true; + const decipher = crypto.createDecipheriv('aes-128-cfb', this._dataDecKey, this._dataDecIV); + const header = decipher.update(buffer.slice(0, 4)); + const v = header[0]; + // const opt = header[1]; + // const cmd = header[2]; + const cmdLen = header[3]; + if (this._v !== v) { + return fail(`server response v doesn't match, expect ${this._v} but got ${v}`); + } + return this._adBuf.put(buffer.slice(4 + cmdLen)/* drop command */, {next, fail}); + } + this._adBuf.put(buffer, {next, fail}); + } + + serverIn({buffer, next, fail}) { + if (!this._isHeaderRecv) { + + if (this._isBroadCasting) { + this._staging = Buffer.concat([this._staging, buffer]); + return; + } + + if (buffer.length < 16) { + return fail(`fail to parse request header: ${buffer.toString('hex')}`); + } + + const {uuid, userHashCache} = V2rayVmessPreset; + + // verify auth info + const authInfo = buffer.slice(0, 16); + const cacheItem = userHashCache.find(({authInfo: auth}) => auth.equals(authInfo)); + if (cacheItem === undefined) { + return fail(`cannot find ${authInfo.toString('hex')} in cache, maybe a wrong auth info`); + } + + // decrypt request header + const ts = ntb(cacheItem.timestamp, 8); + const decipher = crypto.createDecipheriv( + 'aes-128-cfb', + hash('md5', Buffer.concat([uuid, Buffer.from('c48619fe-8f02-49e0-b9e9-edf763e17e21')])), + hash('md5', Buffer.concat([ts, ts, ts, ts])) + ); + const reqCommand = Buffer.from(buffer.slice(16)); + if (reqCommand.length < 41) { + return fail(`request command is too short: ${reqCommand.length}bytes, command=${reqCommand.toString('hex')}`); + } + + // decrypt the leading 41 bytes + const reqHeader = decipher.update(reqCommand.slice(0, 41)); + + // verify version number + const ver = reqHeader[0]; + if (ver !== 0x01) { + return fail(`invalid version number: ${ver}`); + } + + this._dataDecIV = reqHeader.slice(1, 17); + this._dataDecKey = reqHeader.slice(17, 33); + this._dataEncIV = hash('md5', this._dataDecIV); + this._dataEncKey = hash('md5', this._dataDecKey); + this._chunkLenDecMaskGenerator = shake128(this._dataDecIV); + this._chunkLenEncMaskGenerator = shake128(this._dataEncIV); + this._v = reqHeader[33]; + this._opt = reqHeader[34]; + + const paddingLen = reqHeader[35] >> 4; + const securityType = reqHeader[35] & 0x0f; + // const rsv = reqHeader[36]; + + const cmd = reqHeader[37]; + if (![0x01/* tcp, 0x02 udp */].includes(cmd)) { + return fail(`unsupported cmd: ${cmd}`); + } + const port = reqHeader.readUInt16BE(38); + + // addressing + const addrType = reqHeader[40]; + let addr = null; + let offset = 40; + if (addrType === ATYP_V4) { + if (reqCommand.length < 45) { + return fail(`request command is too short ${reqCommand.length}bytes to get ipv4, command=${reqCommand.toString('hex')}`); + } + addr = decipher.update(reqCommand.slice(41, 45)); + offset += 4; + } else if (addrType === ATYP_V6) { + if (reqCommand.length < 57) { + return fail(`request command is too short: ${reqCommand.length}bytes to get ipv6, command=${reqCommand.toString('hex')}`); + } + addr = decipher.update(reqCommand.slice(41, 57)); + offset += 16; + } else if (addrType === ATYP_DOMAIN) { + if (reqCommand.length < 42) { + return fail(`request command is too short: ${reqCommand.length}bytes to get host name, command=${reqCommand.toString('hex')}`); + } + const addrLen = decipher.update(reqCommand.slice(41, 42))[0]; + if (reqCommand.length < 42 + addrLen) { + return fail(`request command is too short: ${reqCommand.length}bytes, command=${reqCommand.toString('hex')}`); + } + addr = decipher.update(reqCommand.slice(42, 42 + addrLen)); + offset += 1 + addrLen; + } else { + return fail(`unknown address type: ${addrType}, command=${reqHeader.toString('hex')}`); + } + if (reqCommand.length < offset + paddingLen + 4) { + return fail(`request command is too short: ${reqCommand.length}bytes to get padding and f, command=${reqCommand.toString('hex')}`); + } + + // padding and F + const padding = decipher.update(reqCommand.slice(offset, offset + paddingLen)); + offset += paddingLen; + const f = decipher.update(reqCommand.slice(offset, offset + 4)); + + // verify F + const plainReqHeader = Buffer.from([ + ...reqHeader.slice(0, 41), + ...(addrType === ATYP_DOMAIN ? [addr.length] : []), ...addr, + ...padding + ]); + if (fnv1a(plainReqHeader).equals(f)) { + return fail('fail to verify request command'); + } + const data = buffer.slice(16 + plainReqHeader.length + 4); + V2rayVmessPreset.security = securityType; + + this._isBroadCasting = true; + this.broadcast({ + type: CONNECT_TO_REMOTE, + payload: { + host: addrType === ATYP_DOMAIN ? addr.toString() : ip.toString(addr), + port: port, + onConnected: () => { + this._adBuf.put(Buffer.concat([data, this._staging]), {next, fail}); + this._isHeaderRecv = true; + this._isBroadCasting = false; + this._staging = null; + } + } + }); + } else { + this._adBuf.put(buffer, {next, fail}); + } + } + + createRequestHeader() { + const rands = crypto.randomBytes(33); + + // IV and Key for data chunks encryption/decryption + this._dataEncIV = rands.slice(0, 16); + this._dataEncKey = rands.slice(16, 32); + this._dataDecKey = hash('md5', this._dataEncKey); + this._dataDecIV = hash('md5', this._dataEncIV); + this._chunkLenEncMaskGenerator = shake128(this._dataEncIV); + this._chunkLenDecMaskGenerator = shake128(this._dataDecIV); + + this._v = rands[32]; + this._opt = 0x05; + + const {userHashCache, uuid} = V2rayVmessPreset; + const {timestamp, authInfo} = userHashCache[getRandomInt(0, userHashCache.length - 1)]; + + // utc timestamp: Big-Endian, 8 bytes + const ts = ntb(timestamp, 8); + + const paddingLen = getRandomInt(0, 15); + const padding = crypto.randomBytes(paddingLen); + + // create encrypted command + let command = Buffer.from([ + 0x01, // Ver + ...this._dataEncIV, ...this._dataEncKey, this._v, this._opt, + paddingLen << 4 | V2rayVmessPreset.security, + 0x00, // RSV + 0x01, // Cmd + ...this._port, this._atyp, + ...Buffer.concat([ + (this._atyp === ATYP_DOMAIN) ? ntb(this._host.length, 1) : Buffer.alloc(0), + this._host + ]), + ...padding + ]); + command = Buffer.concat([command, fnv1a(command)]); + const cipher = crypto.createCipheriv( + 'aes-128-cfb', + hash('md5', Buffer.concat([uuid, Buffer.from('c48619fe-8f02-49e0-b9e9-edf763e17e21')])), + hash('md5', Buffer.concat([ts, ts, ts, ts])) + ); + command = cipher.update(command); + return Buffer.concat([authInfo, command]); + } + + createResponseHeader() { + const cipher = crypto.createCipheriv('aes-128-cfb', this._dataEncKey, this._dataEncIV); + return cipher.update(Buffer.from([this._v, 0x01, 0x00, 0x00])); + } + + getBufferChunks(buffer) { + return getChunks(buffer, 0x3fff).map((chunk) => { + let _chunk = chunk; + if (V2rayVmessPreset.security === SECURITY_TYPE_AES_128_GCM) { + _chunk = Buffer.concat(this.encrypt(_chunk)); + } + let _len = _chunk.length; + if (this._opt === 0x05) { + const mask = this._chunkLenEncMaskGenerator.nextBytes(2).readUInt16BE(0); + _len = mask ^ _len; + } + return Buffer.concat([ntb(_len), _chunk]); + }); + } + + onReceiving(buffer) { + let len = buffer.readUInt16BE(0); + if (this._opt === 0x05) { + const mask = this._chunkLenDecMaskGenerator.nextBytes(2).readUInt16BE(0); + len = mask ^ len; + } + return 2 + len; + } + + onChunkReceived(chunk, {next, fail}) { + if (V2rayVmessPreset.security === SECURITY_TYPE_AES_128_GCM) { + const tag = chunk.slice(-16); + const data = this.decrypt(chunk.slice(2, -16), tag); + if (data === null) { + return fail(`fail to verify data chunk, dump=${chunk.slice(0, 60).toString('hex')}`); + } + return next(data); + } + return next(chunk.slice(2)); + } + + encrypt(plaintext) { + const iv = Buffer.concat([ntb(this._cipherNonce), this._dataEncIV.slice(2, 12)]); + const cipher = crypto.createCipheriv('aes-128-gcm', this._dataEncKey, iv); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + this._cipherNonce += 1; + return [encrypted, tag]; + } + + decrypt(ciphertext, tag) { + const iv = Buffer.concat([ntb(this._decipherNonce), this._dataDecIV.slice(2, 12)]); + const decipher = crypto.createDecipheriv('aes-128-gcm', this._dataDecKey, iv); + decipher.setAuthTag(tag); + try { + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + this._decipherNonce += 1; + return decrypted; + } catch (err) { + return null; + } + } + +}