Skip to content

Commit 88b75c8

Browse files
committed
feat: add support for VLESS Encryptyion
- Implemented resolveEncryptionFromDecryption function to map decryption settings to encryption. - Updated FormatHostsService to utilize the new encryption mapping. - Adjusted MihomoGeneratorService and Xray generator services to incorporate encryption. - Enhanced formatted and raw host interfaces to include encryption property. - Bumped version to 2.1.68 in the contract library.
1 parent 5f5b882 commit 88b75c8

File tree

12 files changed

+333
-3
lines changed

12 files changed

+333
-3
lines changed

libs/contract/commands/subscriptions/get-by/get-raw-subscription-by-short-uuid.command.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export namespace GetRawSubscriptionByShortUuidCommand {
8080
shuffleHost: z.optional(z.nullable(z.boolean())),
8181
mihomoX25519: z.optional(z.nullable(z.boolean())),
8282
mldsa65Verify: z.optional(z.nullable(z.string())),
83+
encryption: z.optional(z.nullable(z.string())),
8384
protocolOptions: z.optional(
8485
z.nullable(
8586
z.object({

libs/contract/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@remnawave/backend-contract",
3-
"version": "2.1.67",
3+
"version": "2.1.68",
44
"public": true,
55
"license": "AGPL-3.0-only",
66
"description": "A contract library for Remnawave Backend. It can be used in backend and frontend.",
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import { createPrivateKey, createPublicKey } from 'node:crypto';
2+
import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
3+
4+
enum KeyType {
5+
MLKEM768 = 'mlkem768',
6+
X25519 = 'x25519',
7+
}
8+
9+
interface IKeyWithType {
10+
type: KeyType;
11+
value: string;
12+
}
13+
14+
interface IDecryptionParsed {
15+
protocol: string; // 'mlkem768x25519plus'
16+
mode: string; // 'native' | 'xorpub' | 'random'
17+
ticketLifetime: string; // '600s' | '0s' | '300-600s'
18+
padding: string; // '100-111-1111.75-0-111.50-0-3333' or empty
19+
keys: IKeyWithType[]; // array of keys (can be X25519 or ML-KEM-768 in any order)
20+
}
21+
22+
interface IPublicKeyWithType {
23+
type: KeyType;
24+
value: string;
25+
}
26+
27+
interface IEncryptionGenerated {
28+
encryption: string;
29+
publicKeys: IPublicKeyWithType[]; // array of public keys in the same order
30+
}
31+
32+
function detectKeyType(keyValue: string): KeyType {
33+
const buffer = Buffer.from(keyValue, 'base64');
34+
const length = buffer.length;
35+
36+
if (length === 32) {
37+
return KeyType.X25519;
38+
}
39+
40+
if (length === 64) {
41+
return KeyType.MLKEM768;
42+
}
43+
44+
throw new Error(
45+
`Cannot detect key type: length ${length}. ` +
46+
`Expected 32 bytes for X25519 or 64 bytes for ML-KEM-768`,
47+
);
48+
}
49+
50+
/**
51+
* Parses the decryption string (server configuration)
52+
*
53+
* Format: mlkem768x25519plus.{mode}.{ticket}.{padding}.{keys}...
54+
* Keys can be X25519 (32 bytes) or ML-KEM-768 (64 bytes) in any order
55+
*
56+
* @param decryption - decryption string from server configuration
57+
* @returns parsed object with parameters and keys
58+
* @throws Error if the string format is incorrect
59+
*
60+
*/
61+
export function parseDecryption(decryption: string): IDecryptionParsed {
62+
if (!decryption || typeof decryption !== 'string') {
63+
throw new Error('Decryption string is required');
64+
}
65+
66+
const parts = decryption.split('.');
67+
68+
if (parts.length < 5) {
69+
throw new Error(
70+
`Invalid decryption format. Expected at least 5 parts, got ${parts.length}. ` +
71+
`Format: mlkem768x25519plus.{mode}.{ticket}.{padding}.{keys}...`,
72+
);
73+
}
74+
75+
const [protocol, mode, ticketLifetime, ...rest] = parts;
76+
77+
if (protocol !== 'mlkem768x25519plus') {
78+
throw new Error(`Invalid protocol: ${protocol}. Expected 'mlkem768x25519plus'`);
79+
}
80+
81+
const validModes = ['native', 'xorpub', 'random'];
82+
if (!validModes.includes(mode)) {
83+
throw new Error(`Invalid mode: ${mode}. Expected one of: ${validModes.join(', ')}`);
84+
}
85+
86+
if (!ticketLifetime || ticketLifetime.length === 0) {
87+
throw new Error('Ticket lifetime is required (e.g., "600s", "0s", "300-600s")');
88+
}
89+
90+
const paddingParts: string[] = [];
91+
const keyParts: string[] = [];
92+
let foundFirstKey = false;
93+
94+
for (const part of rest) {
95+
if (!part || part.trim().length === 0) {
96+
if (!foundFirstKey) {
97+
paddingParts.push(part);
98+
}
99+
continue;
100+
}
101+
102+
try {
103+
const buffer = Buffer.from(part, 'base64');
104+
const length = buffer.length;
105+
106+
if (length === 32 || length === 64) {
107+
foundFirstKey = true;
108+
keyParts.push(part);
109+
} else if (!foundFirstKey) {
110+
paddingParts.push(part);
111+
} else {
112+
throw new Error(
113+
`Invalid key length: ${length} bytes at position after keys started`,
114+
);
115+
}
116+
} catch (error) {
117+
if (!foundFirstKey) {
118+
paddingParts.push(part);
119+
} else {
120+
throw new Error(
121+
`Invalid key format: ${error instanceof Error ? error.message : 'Unknown error'}`,
122+
);
123+
}
124+
}
125+
}
126+
127+
if (keyParts.length === 0) {
128+
throw new Error('At least one key is required');
129+
}
130+
131+
const keys: IKeyWithType[] = [];
132+
for (const keyValue of keyParts) {
133+
if (!keyValue || keyValue.trim().length === 0) {
134+
continue;
135+
}
136+
137+
const keyType = detectKeyType(keyValue);
138+
keys.push({
139+
type: keyType,
140+
value: keyValue,
141+
});
142+
}
143+
144+
if (keys.length === 0) {
145+
throw new Error('No valid keys found in decryption string');
146+
}
147+
148+
const padding = paddingParts.join('.') || '';
149+
150+
return {
151+
protocol,
152+
mode,
153+
ticketLifetime,
154+
padding,
155+
keys,
156+
};
157+
}
158+
159+
export async function generateX25519PublicKey(privateKeyBase64: string): Promise<string> {
160+
try {
161+
const rawPrivateKey = Buffer.from(privateKeyBase64, 'base64');
162+
163+
if (rawPrivateKey.length !== 32) {
164+
throw new Error(
165+
`Invalid X25519 private key length: ${rawPrivateKey.length}. Expected 32 bytes`,
166+
);
167+
}
168+
169+
const jwkPrivateKey = {
170+
kty: 'OKP',
171+
crv: 'X25519',
172+
d: rawPrivateKey.toString('base64url'),
173+
x: '',
174+
};
175+
176+
const privateKeyObj = createPrivateKey({
177+
key: jwkPrivateKey,
178+
format: 'jwk',
179+
});
180+
181+
const publicKeyObj = createPublicKey(privateKeyObj);
182+
const publicKeyJwk = publicKeyObj.export({ format: 'jwk' });
183+
184+
if (!publicKeyJwk.x) {
185+
throw new Error('Failed to generate public key: missing x coordinate');
186+
}
187+
188+
return publicKeyJwk.x;
189+
} catch (error) {
190+
throw new Error(
191+
`Failed to generate X25519 public key: ${error instanceof Error ? error.message : 'Unknown error'}`,
192+
);
193+
}
194+
}
195+
196+
export function generateMlkem768PublicKey(seedBase64: string): string {
197+
try {
198+
const seedBuffer = Buffer.from(seedBase64, 'base64');
199+
200+
if (seedBuffer.length !== 64) {
201+
throw new Error(
202+
`Invalid ML-KEM-768 seed length: ${seedBuffer.length}. Expected 64 bytes`,
203+
);
204+
}
205+
206+
const { publicKey } = ml_kem768.keygen(seedBuffer);
207+
208+
return Buffer.from(publicKey).toString('base64url');
209+
} catch (error) {
210+
throw new Error(
211+
`Failed to generate ML-KEM-768 public key: ${error instanceof Error ? error.message : 'Unknown error'}`,
212+
);
213+
}
214+
}
215+
216+
/**
217+
* Generates the encryption string from the decryption string
218+
*
219+
*
220+
* @param decryption - decryption string from server configuration
221+
* @returns object with encryption string and array of public keys
222+
* @throws Error if the decryption is invalid or the generation of keys failed
223+
*
224+
*/
225+
export async function generateEncryptionFromDecryption(
226+
decryption: string,
227+
): Promise<IEncryptionGenerated> {
228+
const parsed = parseDecryption(decryption);
229+
230+
const publicKeys: IPublicKeyWithType[] = [];
231+
232+
for (const key of parsed.keys) {
233+
try {
234+
let publicKeyValue: string;
235+
236+
if (key.type === KeyType.X25519) {
237+
publicKeyValue = await generateX25519PublicKey(key.value);
238+
} else {
239+
// ML-KEM-768
240+
publicKeyValue = generateMlkem768PublicKey(key.value);
241+
}
242+
243+
publicKeys.push({
244+
type: key.type,
245+
value: publicKeyValue,
246+
});
247+
} catch (error) {
248+
throw new Error(
249+
`Failed to generate public key for ${key.type}: ${error instanceof Error ? error.message : 'Unknown error'}`,
250+
);
251+
}
252+
}
253+
254+
let rttMode = '0rtt';
255+
256+
if (parsed.ticketLifetime === '0s') {
257+
rttMode = '0rtt';
258+
} else {
259+
rttMode = '1rtt';
260+
}
261+
262+
const flatKeys: string[] = publicKeys.map((key) => key.value);
263+
264+
const encryptionParts = [
265+
parsed.protocol, // mlkem768x25519plus
266+
parsed.mode, // native/xorpub/random
267+
rttMode, // 0rtt/1rtt
268+
parsed.padding, // padding parameters (can be empty)
269+
...flatKeys, // public keys in the same order
270+
];
271+
272+
const encryption = encryptionParts.join('.');
273+
274+
return {
275+
encryption,
276+
publicKeys,
277+
};
278+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './generate-encryption-from-decryption';

src/common/helpers/xray-config/interfaces/protocol-settings.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export interface VLessFallback {
4444
// VLess Protocol Settings
4545
export interface VLessSettings {
4646
clients: VLessUser[];
47-
decryption: 'none';
47+
decryption: 'none' | string;
4848
fallbacks?: VLessFallback[];
4949
}
5050

src/common/helpers/xray-config/resolve-public-key.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { createPrivateKey, createPublicKey, KeyObject } from 'node:crypto';
22
import { ml_dsa65 } from '@noble/post-quantum/ml-dsa.js';
33

4+
import { generateEncryptionFromDecryption } from '../vless-encryption/generate-encryption-from-decryption';
5+
46
export async function resolveInboundAndPublicKey(inbounds: any[]): Promise<Map<string, string>> {
57
const publicKeyMap = new Map<string, string>();
68

@@ -67,6 +69,36 @@ export async function resolveInboundAndMlDsa65PublicKey(
6769
return mldsa65PublicKeyMap;
6870
}
6971

72+
export async function resolveEncryptionFromDecryption(
73+
inbounds: any[],
74+
): Promise<Map<string, string>> {
75+
const encryptionMap = new Map<string, string>();
76+
77+
for (const inbound of inbounds) {
78+
if (inbound.protocol !== 'vless') {
79+
continue;
80+
}
81+
82+
if (!inbound.settings) {
83+
continue;
84+
}
85+
86+
if (!inbound.settings.decryption) {
87+
continue;
88+
}
89+
90+
if (inbound.settings.decryption === 'none') {
91+
continue;
92+
}
93+
94+
const encryption = await generateEncryptionFromDecryption(inbound.settings.decryption);
95+
96+
encryptionMap.set(inbound.tag, encryption.encryption);
97+
}
98+
99+
return encryptionMap;
100+
}
101+
70102
async function createX25519KeyPairFromBase64(base64PrivateKey: string): Promise<{
71103
publicKey: KeyObject;
72104
privateKey: KeyObject;

src/modules/subscription-template/generators/format-hosts.service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
xHttpObject,
1515
} from '@common/helpers/xray-config/interfaces/transport.config';
1616
import {
17+
resolveEncryptionFromDecryption,
1718
resolveInboundAndMlDsa65PublicKey,
1819
resolveInboundAndPublicKey,
1920
} from '@common/helpers/xray-config';
@@ -112,6 +113,9 @@ export class FormatHostsService {
112113
const mldsa65PublicKeyMap = await resolveInboundAndMlDsa65PublicKey(
113114
hosts.map((host) => host.rawInbound),
114115
);
116+
const encryptionMap = await resolveEncryptionFromDecryption(
117+
hosts.map((host) => host.rawInbound),
118+
);
115119

116120
const knownRemarks = new Map<string, number>();
117121

@@ -415,6 +419,7 @@ export class FormatHostsService {
415419
mihomoX25519: inputHost.mihomoX25519,
416420
dbData,
417421
mldsa65Verify: mldsa65PublicKeyFromConfig,
422+
encryption: encryptionMap.get(inputHost.inboundTag),
418423
});
419424
}
420425

src/modules/subscription-template/generators/interfaces/formatted-hosts.interface.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,5 @@ export interface IFormattedHost {
3636
mihomoX25519?: boolean;
3737
dbData?: IDbHostData;
3838
mldsa65Verify?: string;
39+
encryption?: string;
3940
}

src/modules/subscription-template/generators/interfaces/raw-host.interface.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,5 @@ export interface IRawHost {
5252
method: string;
5353
};
5454
};
55+
encryption?: string;
5556
}

0 commit comments

Comments
 (0)