diff --git a/android/app/build.gradle b/android/app/build.gradle index 5092ecb..dff8580 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -76,9 +76,8 @@ android { shrinkResources true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } - debug { + debug { signingConfig signingConfigs.release - minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } @@ -89,5 +88,4 @@ flutter { } dependencies { - implementation group: 'com.github.ben-manes.caffeine', name: 'guava', version: '3.1.8' } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/network/proxy/vpn/util/ProcessInfoManager.kt b/android/app/src/main/kotlin/com/network/proxy/vpn/util/ProcessInfoManager.kt index 8438842..247941d 100644 --- a/android/app/src/main/kotlin/com/network/proxy/vpn/util/ProcessInfoManager.kt +++ b/android/app/src/main/kotlin/com/network/proxy/vpn/util/ProcessInfoManager.kt @@ -5,7 +5,6 @@ import android.net.ConnectivityManager import android.os.Build import android.os.Process import android.system.OsConstants -import com.google.common.cache.CacheBuilder import com.network.proxy.plugin.ProcessInfo import com.network.proxy.vpn.Connection import java.net.InetSocketAddress @@ -24,13 +23,12 @@ class ProcessInfoManager private constructor() { class NetworkInfo(val uid: Int, val remoteHost: String, val remotePort: Int) - private val localPortMap = - CacheBuilder.newBuilder().maximumSize(10_000).expireAfterAccess(60, TimeUnit.SECONDS) - .build() + private val localPortCache = + SimpleCache(10_000, 60, TimeUnit.SECONDS) + + + private val appInfoCache = SimpleCache(10_000, 300, TimeUnit.SECONDS) - private val appInfoCache = - CacheBuilder.newBuilder().maximumSize(10_000).expireAfterAccess(300, TimeUnit.SECONDS) - .build() var activity: Context? = null @@ -51,7 +49,7 @@ class ProcessInfoManager private constructor() { val localAddress = channel.localAddress as InetSocketAddress val networkInfo = NetworkInfo(uid, destinationAddress.hostString, destinationAddress.port) - localPortMap.put(localAddress.port, networkInfo) + localPortCache.put(localAddress.port, networkInfo) } } @@ -63,7 +61,7 @@ class ProcessInfoManager private constructor() { val channel = connection.channel if (channel is SocketChannel) { val localAddress = channel.localAddress as InetSocketAddress - localPortMap.invalidate(localAddress.port) + localPortCache.remove(localAddress.port) } } @@ -102,7 +100,7 @@ class ProcessInfoManager private constructor() { } fun getProcessInfoByPort(localPort: Int): ProcessInfo? { - val networkInfo = localPortMap.getIfPresent(localPort) + val networkInfo = localPortCache.get(localPort) if (networkInfo != null) { val processInfo = getProcessInfo(networkInfo.uid) @@ -115,7 +113,7 @@ class ProcessInfoManager private constructor() { } private fun getProcessInfo(uid: Int): ProcessInfo? { - var appInfo = appInfoCache.getIfPresent(uid) + var appInfo = appInfoCache.get(uid) if (appInfo != null) return appInfo val packageManager = activity?.packageManager diff --git a/android/app/src/main/kotlin/com/network/proxy/vpn/util/SimpleCache.kt b/android/app/src/main/kotlin/com/network/proxy/vpn/util/SimpleCache.kt new file mode 100644 index 0000000..07f4956 --- /dev/null +++ b/android/app/src/main/kotlin/com/network/proxy/vpn/util/SimpleCache.kt @@ -0,0 +1,68 @@ +package com.network.proxy.vpn.util + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class SimpleCache( + private val maxSize: Int, + private val expireAfterAccess: Long, + private val timeUnit: TimeUnit +) { + private val cache = ConcurrentHashMap>() + + companion object { + private val EXECUTOR = Executors.newSingleThreadScheduledExecutor() + } + + + init { + EXECUTOR.scheduleWithFixedDelay( + { cleanUp() }, + expireAfterAccess, + expireAfterAccess, + timeUnit + ) + } + + fun put(key: K, value: V) { + if (cache.size >= maxSize) { + cache.keys.iterator().next()?.let { cache.remove(it) } + } + cache[key] = CacheEntry(value, System.nanoTime()) + } + + fun get(key: K): V? { + val entry = cache[key] ?: return null + if (System.nanoTime() - entry.lastAccessTime > timeUnit.toNanos(expireAfterAccess)) { + cache.remove(key) + return null + } + + entry.lastAccessTime = System.nanoTime() + return entry.value + } + + fun remove(key: K) { + cache.remove(key) + } + + fun clear() { + cache.clear() + } + + private fun cleanUp() { + val now = System.nanoTime() + val expirationTime = timeUnit.toNanos(expireAfterAccess) + + val iterator = cache.entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (now - entry.value.lastAccessTime > expirationTime) { + iterator.remove() + } + } + } + + private data class CacheEntry(val value: V, var lastAccessTime: Long) +} \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8f30b3c..e392b28 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -178,6 +178,7 @@ "generateCADescribe": "Are you sure you want to generate a new root certificate? If confirmed,\nYou need to reinstall and trust the new certificate", "resetDefaultCA": "Reset Default Root Certificate", "resetDefaultCADescribe": "Are you sure you want to reset to the default root certificate?\nProxyPin default root certificate is the same for all users.", + "exportCaP12": "Export Root Certificate(.p12)", "trustCa": "Trust Certificate", "profileDownload": "Profile Download", "exportCA": "Export Root Certificate", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 95fa651..8387d1c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -179,6 +179,7 @@ "generateCADescribe": "您确定要生成新的根证书吗? 如果确认,\n则需要重新安装并信任新的证书", "resetDefaultCA": "重置默认根证书", "resetDefaultCADescribe": "确定要重置为默认根证书吗? ProxyPin默认\n根证书对所有用户都是相同的.", + "exportCaP12": "导出根证书(.p12)", "trustCa": "信任证书", "exportCA": "导出根证书", "exportPrivateKey": "导出私钥", diff --git a/lib/network/handler.dart b/lib/network/handler.dart index 620434a..d1e69a7 100644 --- a/lib/network/handler.dart +++ b/lib/network/handler.dart @@ -253,7 +253,7 @@ class HttpResponseProxyHandler extends ChannelHandler { } msg = response; } catch (e, t) { - msg.status = HttpStatus(-1, Localizations.isEN ? 'Script exec error' : '执行脚本异常'); + msg.status = HttpStatus(-1, 'Script exec error'); msg.body = "$e\n${msg.bodyAsString}".codeUnits; log.e('[${clientChannel.id}] 执行脚本异常 ', error: e, stackTrace: t); } diff --git a/lib/network/proxy_helper.dart b/lib/network/proxy_helper.dart index abd17c7..28479d9 100644 --- a/lib/network/proxy_helper.dart +++ b/lib/network/proxy_helper.dart @@ -74,7 +74,7 @@ class ProxyHelper { HttpStatus status = HttpStatus(-1, message); if (error is HandshakeException) { status = HttpStatus( - -2, Localizations.isEN ? 'SSL handshake failed, please check the certificate' : 'SSL握手失败,请检查证书安装是否正确'); + -2, Localizations.isEN ? 'SSL handshake failed, please check the certificate' : 'SSL handshake failed, 请检查证书安装是否正确'); } else if (error is ParserException) { status = HttpStatus(-3, error.message); } else if (error is SocketException) { diff --git a/lib/network/util/crts.dart b/lib/network/util/crts.dart index ca11cad..e7faf38 100644 --- a/lib/network/util/crts.dart +++ b/lib/network/util/crts.dart @@ -17,6 +17,7 @@ import 'dart:core'; import 'dart:io'; import 'dart:math'; +import 'dart:typed_data'; import 'package:basic_utils/basic_utils.dart'; import 'package:network_proxy/network/util/x509.dart'; @@ -94,7 +95,10 @@ class CertificateManager { x509Subject['CN'] = host; var csrPem = X509Generate.generateSelfSignedCertificate(caRoot, serverPubKey, caPriKey, 365, - sans: [host], serialNumber: Random().nextInt(1000000).toString(), subject: x509Subject); + extKeyUsage: [ExtendedKeyUsage.SERVER_AUTH], + sans: [host], + serialNumber: Random().nextInt(1000000).toString(), + subject: x509Subject); return csrPem; } @@ -182,4 +186,11 @@ class CertificateManager { return caFile; } + + ///生成 p12文件 + static Future generatePkcs12(String? password) async { + var caFile = await CertificateManager.certificateFile(); + var keyFile = await CertificateManager.privateKeyFile(); + return Pkcs12Utils.generatePkcs12(await keyFile.readAsString(), [await caFile.readAsString()], password: password); + } } diff --git a/lib/network/util/x509.dart b/lib/network/util/x509.dart index 38f9919..57339ec 100644 --- a/lib/network/util/x509.dart +++ b/lib/network/util/x509.dart @@ -7,7 +7,6 @@ import 'package:basic_utils/basic_utils.dart'; import 'package:pointycastle/asn1/unsupported_object_identifier_exception.dart'; import 'package:pointycastle/pointycastle.dart'; - /// @author wanghongen /// 2023/7/26 class X509Generate { @@ -39,6 +38,7 @@ class X509Generate { String serialNumber = '1', Map? issuer, Map? subject, + List? extKeyUsage, }) { var data = ASN1Sequence(); @@ -85,25 +85,36 @@ class X509Generate { // Add Public Key data.add(_makePublicKeyBlock(publicKey)); + // Add Extensions - if (IterableUtils.isNotNullOrEmpty(sans)) { + if (IterableUtils.isNotNullOrEmpty(sans) || IterableUtils.isNotNullOrEmpty(extKeyUsage)) { var extensionTopSequence = ASN1Sequence(); - var sanList = ASN1Sequence(); - for (var s in sans!) { - sanList.add(ASN1PrintableString(stringValue: s, tag: 0x82)); + // Add Key Usage + var extKeyUsageSequence = extKeyEncodings(extKeyUsage); + if (extKeyUsageSequence != null) { + extensionTopSequence.add(extKeyUsageSequence); } - var octetString = ASN1OctetString(octets: sanList.encode()); - var sanSequence = ASN1Sequence(); - sanSequence.add(ASN1ObjectIdentifier.fromIdentifierString('2.5.29.17')); - sanSequence.add(octetString); - extensionTopSequence.add(sanSequence); + if (IterableUtils.isNotNullOrEmpty(sans)) { + var extensionTopSequence = ASN1Sequence(); - var extObj = ASN1Object(tag: 0xA3); - extObj.valueBytes = extensionTopSequence.encode(); + var sanList = ASN1Sequence(); + for (var s in sans!) { + sanList.add(ASN1PrintableString(stringValue: s, tag: 0x82)); + } + var octetString = ASN1OctetString(octets: sanList.encode()); - data.add(extObj); + var sanSequence = ASN1Sequence(); + sanSequence.add(ASN1ObjectIdentifier.fromIdentifierString('2.5.29.17')); + sanSequence.add(octetString); + extensionTopSequence.add(sanSequence); + + var extObj = ASN1Object(tag: 0xA3); + extObj.valueBytes = extensionTopSequence.encode(); + + data.add(extObj); + } } var outer = ASN1Sequence(); @@ -117,6 +128,48 @@ class X509Generate { return '$BEGIN_CERT\n${chunks.join('\r\n')}\n$END_CERT'; } + static ASN1Sequence? extKeyEncodings(List? extKeyUsage) { + if (IterableUtils.isNullOrEmpty(extKeyUsage)) { + return null; + } + var extKeyUsageList = ASN1Sequence(); + for (var s in extKeyUsage!) { + var oi = []; + switch (s) { + case ExtendedKeyUsage.SERVER_AUTH: + oi = [1, 3, 6, 1, 5, 5, 7, 3, 1]; + break; + case ExtendedKeyUsage.CLIENT_AUTH: + oi = [1, 3, 6, 1, 5, 5, 7, 3, 2]; + break; + case ExtendedKeyUsage.CODE_SIGNING: + oi = [1, 3, 6, 1, 5, 5, 7, 3, 3]; + break; + case ExtendedKeyUsage.EMAIL_PROTECTION: + oi = [1, 3, 6, 1, 5, 5, 7, 3, 4]; + break; + case ExtendedKeyUsage.TIME_STAMPING: + oi = [1, 3, 6, 1, 5, 5, 7, 3, 8]; + break; + case ExtendedKeyUsage.OCSP_SIGNING: + oi = [1, 3, 6, 1, 5, 5, 7, 3, 9]; + break; + case ExtendedKeyUsage.BIMI: + oi = [1, 3, 6, 1, 5, 5, 7, 3, 31]; + break; + } + + extKeyUsageList.add(ASN1ObjectIdentifier(oi)); + } + + var octetString = ASN1OctetString(octets: extKeyUsageList.encode()); + + var extKeyUsageSequence = ASN1Sequence(); + extKeyUsageSequence.add(ASN1ObjectIdentifier.fromIdentifierString('2.5.29.37')); + extKeyUsageSequence.add(octetString); + return extKeyUsageSequence; + } + static ASN1Set _identifier(String k, String value) { ASN1ObjectIdentifier oIdentifier; try { @@ -134,8 +187,9 @@ class X509Generate { } var innerSequence = ASN1Sequence(elements: [oIdentifier, pString]); - return ASN1Set(elements: [innerSequence]); + return ASN1Set(elements: [innerSequence]); } + static Uint8List _rsaSign(Uint8List inBytes, RSAPrivateKey privateKey, String signingAlgorithm) { var signer = Signer('$signingAlgorithm/RSA'); signer.init(true, PrivateKeyParameter(privateKey)); diff --git a/lib/ui/desktop/toolbar/ssl/ssl.dart b/lib/ui/desktop/toolbar/ssl/ssl.dart index 955ecde..aab954b 100644 --- a/lib/ui/desktop/toolbar/ssl/ssl.dart +++ b/lib/ui/desktop/toolbar/ssl/ssl.dart @@ -86,6 +86,46 @@ class _SslState extends State { await caFile.copy(path); }), const Divider(thickness: 0.3, height: 8), + MenuItemButton( + child: Padding( + padding: const EdgeInsets.only(left: 10, right: 10), + child: Text(localizations.exportCaP12, style: const TextStyle(fontSize: 14))), + onPressed: () async { + //show p12 password + showDialog( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + title: Text(localizations.exportCaP12, style: const TextStyle(fontSize: 16)), + children: [ + Padding( + padding: const EdgeInsets.all(10), + child: TextField( + controller: TextEditingController(), + decoration: const InputDecoration( + hintText: "Enter a password to protect p12 file", + border: OutlineInputBorder(), + ), + ), + ), + Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(localizations.cancel)), + TextButton( + onPressed: () async { + String? path = (await getSaveLocation(suggestedName: "ProxyPinPkcs12.p12"))?.path; + if (path == null) return; + + var password = TextEditingController().text; + var p12Bytes = await CertificateManager.generatePkcs12(password); + await File(path).writeAsBytes(p12Bytes); + if (context.mounted) Navigator.pop(context); + }, + child: Text(localizations.export), + ) + ]) + ]); + }); + }), MenuItemButton( child: Padding( padding: const EdgeInsets.only(left: 10, right: 10), diff --git a/lib/ui/mobile/mobile.dart b/lib/ui/mobile/mobile.dart index f3af5e3..1601988 100644 --- a/lib/ui/mobile/mobile.dart +++ b/lib/ui/mobile/mobile.dart @@ -221,7 +221,7 @@ class MobileHomeState extends State implements EventListener, Li ? '提示:默认不会开启HTTPS抓包,请安装证书后再开启HTTPS抓包。\n\n' '1. 支持自定义根证书;\n' '2. 支持重新生成根证书,以及重置默认跟证书;\n' - '3. 支持导出根证书和私钥;\n' + '3. 支持导出根证书(P12)和私钥;\n' '4. 重放域名下请求;\n' '5. 修复请求重写列表换行问题;\n' '6. 脚本headers支持同名多个值情况;\n' @@ -229,7 +229,7 @@ class MobileHomeState extends State implements EventListener, Li 'Click HTTPS Capture packets(Lock icon),Choose to install the root certificate and follow the prompts to proceed。\n\n' '1. Support custom root certificates;\n' '2. Support generate new root certificates and resetting default root certificates;\n' - '3. Support exporting root certificates and private keys;\n' + '3. Support exporting root certificates(P12) and private keys;\n' '4. Replay domain name request;\n' '5. Fix request rewrite list word wrapping;\n' '6. Script headers support multiple values with the same name;\n'; diff --git a/lib/ui/mobile/setting/ssl.dart b/lib/ui/mobile/setting/ssl.dart index 898c07b..66963c2 100644 --- a/lib/ui/mobile/setting/ssl.dart +++ b/lib/ui/mobile/setting/ssl.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; @@ -59,7 +60,7 @@ class _MobileSslState extends State { Navigator.push(context, MaterialPageRoute(builder: (context) => Platform.isIOS ? ios() : const AndroidCaInstall())); }), - const Divider(indent: 0.2, height: 2), + const Divider(indent: 0.2, height: 1), ListTile( title: Text(localizations.exportCA), onTap: () async { @@ -68,15 +69,51 @@ class _MobileSslState extends State { return; } var caFile = await CertificateManager.certificateFile(); - _exportFile("ProxyPinCA.crt", caFile); + _exportFile("ProxyPinCA.crt", file: caFile); + }), + ListTile( + title: Text(localizations.exportCaP12), + onTap: () async { + //show p12 password + showDialog( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + title: Text(localizations.exportCaP12, style: const TextStyle(fontSize: 16)), + children: [ + Padding( + padding: const EdgeInsets.all(10), + child: TextField( + controller: TextEditingController(), + decoration: const InputDecoration( + hintText: "Enter a password to protect p12 file", + border: OutlineInputBorder(), + ), + ), + ), + Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(localizations.cancel)), + TextButton( + onPressed: () async { + var password = TextEditingController().text; + var p12Bytes = await CertificateManager.generatePkcs12(password); + _exportFile("ProxyPinPkcs12.p12", bytes: p12Bytes); + + if (context.mounted) Navigator.pop(context); + }, + child: Text(localizations.export), + ) + ]) + ]); + }); }), ListTile( title: Text(localizations.exportPrivateKey), onTap: () async { var keyFile = await CertificateManager.privateKeyFile(); - _exportFile("ProxyPinKey.pem", keyFile); + _exportFile("ProxyPinKey.pem", file: keyFile); }), - const Divider(indent: 0.2, height: 2), + const Divider(indent: 0.2, height: 1), ListTile( title: Text(localizations.generateCA), onTap: () async { @@ -86,7 +123,7 @@ class _MobileSslState extends State { if (context.mounted) FlutterToastr.show(localizations.success, context); }); }), - const Divider(indent: 0.2, height: 2), + const Divider(indent: 0.2, height: 1), ListTile( title: Text(localizations.resetDefaultCA), onTap: () async { @@ -137,9 +174,11 @@ class _MobileSslState extends State { launchUrl(Uri.parse("http://127.0.0.1:${widget.proxyServer.port}/ssl"), mode: LaunchMode.externalApplication); } - void _exportFile(String name, File file) async { + void _exportFile(String name, {File? file, Uint8List? bytes}) async { + bytes ??= await file!.readAsBytes(); + String? outputFile = await FilePicker.platform - .saveFile(dialogTitle: 'Please select the path to save:', fileName: name, bytes: await file.readAsBytes()); + .saveFile(dialogTitle: 'Please select the path to save:', fileName: name, bytes: bytes); if (outputFile != null && mounted) { AppLocalizations localizations = AppLocalizations.of(context)!; diff --git a/pubspec.yaml b/pubspec.yaml index 949f55b..4a6077d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: network_proxy description: ProxyPin publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 1.1.0+9 +version: 1.1.1+10 environment: sdk: '>=3.0.2 <4.0.0' diff --git a/test/cert_test.dart b/test/cert_test.dart index 4382f87..84e007f 100644 --- a/test/cert_test.dart +++ b/test/cert_test.dart @@ -1,11 +1,10 @@ import 'dart:io'; import 'dart:math'; import 'package:basic_utils/basic_utils.dart'; -import 'package:network_proxy/network/util/file_read.dart'; import 'package:network_proxy/network/util/x509.dart'; void main() async { - var caPem = await FileRead.readAsString('assets/certs/ca.crt'); + var caPem = await File('assets/certs/ca.crt').readAsString(); //生成 公钥和私钥 var caRoot = X509Utils.x509CertificateFromPem(caPem); var generateRSAKeyPair = CryptoUtils.generateRSAKeyPair(); @@ -20,7 +19,25 @@ void main() async { print(rsaPrivateKeyFromPem); var crt = generate(caRoot, serverPubKey, serverPriKey); print(crt); + await File('assets/certs/server.crt').writeAsString(crt); + //TLS服务器证书必须包含ExtendedKeyUsage(EKU)扩展,该扩展包含id-kp-serverAuth OID。 + X509Utils.generateSelfSignedCertificate(serverPriKey, CryptoUtils.encodeRSAPublicKeyToPem(serverPubKey), 825, + serialNumber: Random().nextInt(1000000).toString(), + issuer: { + 'C': 'CN', + 'ST': 'BJ', + 'L': 'Beijing', + 'O': 'Proxy', + 'OU': 'ProxyPin', + 'CN': 'ProxyPin CA (${Platform.localHostname})' + }, + extKeyUsage: [ + ExtendedKeyUsage.SERVER_AUTH + ]); + + var generatePkcs12 = Pkcs12Utils.generatePkcs12(serverPriKeyPem, [crt], password: '123456'); + await File('C:\\Users\\wanghongen\\Downloads\\server.p12').writeAsBytes(generatePkcs12); } /// 生成证书 @@ -35,7 +52,10 @@ String generate(X509CertificateData caRoot, RSAPublicKey serverPubKey, RSAPrivat }; x509Subject['CN'] = 'ProxyPin CA (${Platform.localHostname})'; - var csrPem = X509Generate.generateSelfSignedCertificate(caRoot, serverPubKey, caPriKey, 1095, - serialNumber: Random().nextInt(1000000).toString(), subject: x509Subject); + var csrPem = X509Generate.generateSelfSignedCertificate(caRoot, serverPubKey, caPriKey, 825, + extKeyUsage: [ExtendedKeyUsage.SERVER_AUTH], + sans: [x509Subject['CN']!], + serialNumber: Random().nextInt(1000000).toString(), + subject: x509Subject); return csrPem; }