Skip to content

Commit db0dee0

Browse files
committed
feat: Loon 资源解析器支持 age-secret-key 配置 age 解密私钥
1 parent 29f6f43 commit db0dee0

5 files changed

Lines changed: 351 additions & 18 deletions

File tree

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sub-store",
3-
"version": "2.24.1",
3+
"version": "2.24.2",
44
"description": "Advanced Subscription Manager for QX, Loon, Surge, Stash and Shadowrocket.",
55
"main": "src/main.js",
66
"packageManager": "pnpm@11.0.9",

backend/src/products/resource-parser.loon.js

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import { ProxyUtils } from '@/core/proxy-utils';
33
import { RuleUtils } from '@/core/rule-utils';
44
import { version } from '../../package.json';
55
import download from '@/utils/download';
6+
import {
7+
AGE_SECRET_KEY,
8+
decryptArmorIfPresent,
9+
isAgeArmor,
10+
maskAgeSecret,
11+
} from '@/utils/age';
612

713
let result = '';
814
let resource = typeof $resource !== 'undefined' ? $resource : '';
@@ -27,20 +33,29 @@ let resourceUrl = typeof $resourceUrl !== 'undefined' ? $resourceUrl : '';
2733
} else {
2834
arg = {};
2935
}
30-
console.log(`arg: ${JSON.stringify(arg)}`);
36+
console.log(`arg: ${maskAgeSecret(JSON.stringify(arg))}`);
37+
const ageSecretKey = arg?.[AGE_SECRET_KEY];
38+
const downloadOptions = ageSecretKey
39+
? {
40+
[AGE_SECRET_KEY]: ageSecretKey,
41+
}
42+
: undefined;
43+
const maybeDecryptResource = async (input) =>
44+
ageSecretKey ? await decryptArmorIfPresent(input, ageSecretKey) : input;
3145

3246
const RESOURCE_TYPE = {
3347
PROXY: 1,
3448
RULE: 2,
3549
};
3650
if (!arg.resourceUrlOnly) {
37-
result = resource;
51+
result = ageSecretKey && isAgeArmor(resource) ? '' : resource;
3852
}
3953

4054
if (resourceType === RESOURCE_TYPE.PROXY) {
4155
if (!arg.resourceUrlOnly) {
4256
try {
43-
let proxies = ProxyUtils.parse(resource);
57+
const raw = await maybeDecryptResource(resource);
58+
let proxies = ProxyUtils.parse(raw);
4459
result = ProxyUtils.produce(proxies, 'Loon', undefined, {
4560
'include-unsupported-proxy': arg?.includeUnsupportedProxy,
4661
});
@@ -61,6 +76,7 @@ let resourceUrl = typeof $resourceUrl !== 'undefined' ? $resourceUrl : '';
6176
undefined,
6277
arg?.noCache,
6378
true,
79+
downloadOptions,
6480
);
6581
let proxies = ProxyUtils.parse(raw);
6682
result = ProxyUtils.produce(proxies, 'Loon', undefined, {
@@ -73,7 +89,8 @@ let resourceUrl = typeof $resourceUrl !== 'undefined' ? $resourceUrl : '';
7389
} else if (resourceType === RESOURCE_TYPE.RULE) {
7490
if (!arg.resourceUrlOnly) {
7591
try {
76-
const rules = RuleUtils.parse(resource);
92+
const raw = await maybeDecryptResource(resource);
93+
const rules = RuleUtils.parse(raw);
7794
result = RuleUtils.produce(rules, 'Loon');
7895
} catch (e) {
7996
console.log(e.message ?? e);
@@ -82,7 +99,17 @@ let resourceUrl = typeof $resourceUrl !== 'undefined' ? $resourceUrl : '';
8299
if ((!result || /^\s*$/.test(result)) && resourceUrl) {
83100
console.log(`解析器: 尝试从 ${resourceUrl} 获取规则`);
84101
try {
85-
let raw = await download(resourceUrl, arg?.ua, arg?.timeout);
102+
let raw = await download(
103+
resourceUrl,
104+
arg?.ua,
105+
arg?.timeout,
106+
undefined,
107+
undefined,
108+
undefined,
109+
undefined,
110+
undefined,
111+
downloadOptions,
112+
);
86113
let rules = RuleUtils.parse(raw);
87114
result = RuleUtils.produce(rules, 'Loon');
88115
} catch (e) {

backend/src/test/products/resource-parser.loon.spec.js

Lines changed: 278 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,16 @@ import { expect } from 'chai';
22
import { describe, it } from 'mocha';
33

44
import { ProxyUtils } from '@/core/proxy-utils';
5+
import { RuleUtils } from '@/core/rule-utils';
6+
import * as ageUtils from '@/utils/age';
57

68
describe('Loon resource parser', function () {
7-
const modulePath = require.resolve('../../products/resource-parser.loon.js');
9+
this.timeout(10000);
10+
11+
const modulePath = require.resolve(
12+
'../../products/resource-parser.loon.js',
13+
);
14+
const downloadModule = require('@/utils/download');
815

916
function cleanupGlobals() {
1017
delete global.$argument;
@@ -19,6 +26,30 @@ describe('Loon resource parser', function () {
1926
delete require.cache[modulePath];
2027
}
2128

29+
async function runParser({
30+
argument = '',
31+
resource = '',
32+
resourceType = 1,
33+
resourceUrl = '',
34+
} = {}) {
35+
global.$loon = 'Loon(842)';
36+
global.$argument = argument;
37+
global.$resource = resource;
38+
global.$resourceType = resourceType;
39+
global.$resourceUrl = resourceUrl;
40+
41+
return await new Promise((resolve, reject) => {
42+
global.$done = resolve;
43+
44+
try {
45+
resetModule();
46+
require(modulePath);
47+
} catch (error) {
48+
reject(error);
49+
}
50+
});
51+
}
52+
2253
it('does not auto-enable includeUnsupportedProxy from build number', async function () {
2354
const originalParse = ProxyUtils.parse;
2455
const originalProduce = ProxyUtils.produce;
@@ -59,4 +90,250 @@ describe('Loon resource parser', function () {
5990
cleanupGlobals();
6091
}
6192
});
93+
94+
it('decrypts inline age-armored proxy resources from parser arguments', async function () {
95+
const originalParse = ProxyUtils.parse;
96+
const originalProduce = ProxyUtils.produce;
97+
let capturedInput;
98+
99+
try {
100+
const pair = await ageUtils.generateKeyPair();
101+
const armored = await ageUtils.encryptArmor(
102+
'decrypted-proxy-resource',
103+
pair['age-public-key'],
104+
);
105+
106+
ProxyUtils.parse = (input) => {
107+
capturedInput = input;
108+
return [{ type: 'ss' }];
109+
};
110+
ProxyUtils.produce = () => 'proxy-output';
111+
112+
const result = await runParser({
113+
argument: `age-secret-key=${encodeURIComponent(
114+
pair['age-secret-key'],
115+
)}`,
116+
resource: armored,
117+
resourceType: 1,
118+
});
119+
120+
expect(result).to.equal('proxy-output');
121+
expect(capturedInput).to.equal('decrypted-proxy-resource');
122+
} finally {
123+
ProxyUtils.parse = originalParse;
124+
ProxyUtils.produce = originalProduce;
125+
resetModule();
126+
cleanupGlobals();
127+
}
128+
});
129+
130+
it('decrypts inline age-armored rule resources from parser arguments', async function () {
131+
const originalParse = RuleUtils.parse;
132+
const originalProduce = RuleUtils.produce;
133+
let capturedInput;
134+
135+
try {
136+
const pair = await ageUtils.generateKeyPair();
137+
const armored = await ageUtils.encryptArmor(
138+
'DOMAIN,example.com,Proxy',
139+
pair['age-public-key'],
140+
);
141+
142+
RuleUtils.parse = (input) => {
143+
capturedInput = input;
144+
return [{ type: 'DOMAIN', payload: 'example.com' }];
145+
};
146+
RuleUtils.produce = () => 'rule-output';
147+
148+
const result = await runParser({
149+
argument: `age-secret-key=${encodeURIComponent(
150+
pair['age-secret-key'],
151+
)}`,
152+
resource: armored,
153+
resourceType: 2,
154+
});
155+
156+
expect(result).to.equal('rule-output');
157+
expect(capturedInput).to.equal('DOMAIN,example.com,Proxy');
158+
} finally {
159+
RuleUtils.parse = originalParse;
160+
RuleUtils.produce = originalProduce;
161+
resetModule();
162+
cleanupGlobals();
163+
}
164+
});
165+
166+
it('keeps plain inline resources unchanged when age-secret-key is present', async function () {
167+
const originalParse = ProxyUtils.parse;
168+
const originalProduce = ProxyUtils.produce;
169+
let capturedInput;
170+
171+
try {
172+
const pair = await ageUtils.generateKeyPair();
173+
174+
ProxyUtils.parse = (input) => {
175+
capturedInput = input;
176+
return [{ type: 'ss' }];
177+
};
178+
ProxyUtils.produce = () => 'proxy-output';
179+
180+
const result = await runParser({
181+
argument: `age-secret-key=${encodeURIComponent(
182+
pair['age-secret-key'],
183+
)}`,
184+
resource: 'plain-proxy-resource',
185+
resourceType: 1,
186+
});
187+
188+
expect(result).to.equal('proxy-output');
189+
expect(capturedInput).to.equal('plain-proxy-resource');
190+
} finally {
191+
ProxyUtils.parse = originalParse;
192+
ProxyUtils.produce = originalProduce;
193+
resetModule();
194+
cleanupGlobals();
195+
}
196+
});
197+
198+
it('passes parser age-secret-key to remote proxy downloads', async function () {
199+
const originalDownload = downloadModule.default;
200+
const originalParse = ProxyUtils.parse;
201+
const originalProduce = ProxyUtils.produce;
202+
let capturedInput;
203+
let capturedOptions;
204+
205+
try {
206+
const pair = await ageUtils.generateKeyPair();
207+
const armored = await ageUtils.encryptArmor(
208+
'remote-proxy-resource',
209+
pair['age-public-key'],
210+
);
211+
212+
downloadModule.default = async (...args) => {
213+
capturedOptions = args[8];
214+
return capturedOptions?.['age-secret-key'] ===
215+
pair['age-secret-key']
216+
? 'remote-proxy-resource'
217+
: armored;
218+
};
219+
ProxyUtils.parse = (input) => {
220+
capturedInput = input;
221+
return [{ type: 'ss' }];
222+
};
223+
ProxyUtils.produce = () => 'proxy-output';
224+
225+
const result = await runParser({
226+
argument: `resourceUrlOnly=true&age-secret-key=${encodeURIComponent(
227+
pair['age-secret-key'],
228+
)}`,
229+
resource: '',
230+
resourceType: 1,
231+
resourceUrl: 'https://example.com/proxy.txt',
232+
});
233+
234+
expect(result).to.equal('proxy-output');
235+
expect(capturedInput).to.equal('remote-proxy-resource');
236+
expect(capturedOptions).to.deep.include({
237+
'age-secret-key': pair['age-secret-key'],
238+
});
239+
} finally {
240+
downloadModule.default = originalDownload;
241+
ProxyUtils.parse = originalParse;
242+
ProxyUtils.produce = originalProduce;
243+
resetModule();
244+
cleanupGlobals();
245+
}
246+
});
247+
248+
it('passes parser age-secret-key to remote rule downloads', async function () {
249+
const originalDownload = downloadModule.default;
250+
const originalParse = RuleUtils.parse;
251+
const originalProduce = RuleUtils.produce;
252+
let capturedInput;
253+
let capturedOptions;
254+
255+
try {
256+
const pair = await ageUtils.generateKeyPair();
257+
const armored = await ageUtils.encryptArmor(
258+
'DOMAIN,remote.example,Proxy',
259+
pair['age-public-key'],
260+
);
261+
262+
downloadModule.default = async (...args) => {
263+
capturedOptions = args[8];
264+
return capturedOptions?.['age-secret-key'] ===
265+
pair['age-secret-key']
266+
? 'DOMAIN,remote.example,Proxy'
267+
: armored;
268+
};
269+
RuleUtils.parse = (input) => {
270+
capturedInput = input;
271+
return [{ type: 'DOMAIN', payload: 'remote.example' }];
272+
};
273+
RuleUtils.produce = () => 'rule-output';
274+
275+
const result = await runParser({
276+
argument: `resourceUrlOnly=true&age-secret-key=${encodeURIComponent(
277+
pair['age-secret-key'],
278+
)}`,
279+
resource: 'ignored-rule-resource',
280+
resourceType: 2,
281+
resourceUrl: 'https://example.com/rule.txt',
282+
});
283+
284+
expect(result).to.equal('rule-output');
285+
expect(capturedInput).to.equal('DOMAIN,remote.example,Proxy');
286+
expect(capturedOptions).to.deep.include({
287+
'age-secret-key': pair['age-secret-key'],
288+
});
289+
} finally {
290+
downloadModule.default = originalDownload;
291+
RuleUtils.parse = originalParse;
292+
RuleUtils.produce = originalProduce;
293+
resetModule();
294+
cleanupGlobals();
295+
}
296+
});
297+
298+
it('does not expose age-secret-key or return armor on decrypt failure', async function () {
299+
const originalParse = ProxyUtils.parse;
300+
const originalProduce = ProxyUtils.produce;
301+
const originalConsoleLog = console.log;
302+
let logs = [];
303+
304+
try {
305+
const encryptPair = await ageUtils.generateKeyPair();
306+
const wrongPair = await ageUtils.generateKeyPair();
307+
const armored = await ageUtils.encryptArmor(
308+
'decrypted-proxy-resource',
309+
encryptPair['age-public-key'],
310+
);
311+
312+
ProxyUtils.parse = () => {
313+
throw new Error('parse should not succeed');
314+
};
315+
ProxyUtils.produce = () => 'proxy-output';
316+
console.log = (message) => {
317+
logs.push(String(message));
318+
};
319+
320+
const result = await runParser({
321+
argument: `age-secret-key=${encodeURIComponent(
322+
wrongPair['age-secret-key'],
323+
)}`,
324+
resource: armored,
325+
resourceType: 1,
326+
});
327+
328+
expect(result).to.not.contain(ageUtils.AGE_ARMOR_HEADER);
329+
expect(logs.join('\n')).to.not.contain(wrongPair['age-secret-key']);
330+
expect(logs.join('\n')).to.contain('age-secret-key');
331+
} finally {
332+
ProxyUtils.parse = originalParse;
333+
ProxyUtils.produce = originalProduce;
334+
console.log = originalConsoleLog;
335+
resetModule();
336+
cleanupGlobals();
337+
}
338+
});
62339
});

0 commit comments

Comments
 (0)