mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
546 lines
17 KiB
Python
546 lines
17 KiB
Python
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||
#
|
||
# This file is part of Furious.
|
||
#
|
||
# This program is free software: you can redistribute it and/or modify
|
||
# it under the terms of the GNU General Public License as published by
|
||
# the Free Software Foundation, either version 3 of the License, or
|
||
# (at your option) any later version.
|
||
#
|
||
# This program is distributed in the hope that it will be useful,
|
||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
# GNU General Public License for more details.
|
||
#
|
||
# You should have received a copy of the GNU General Public License
|
||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
|
||
"""Parse and serialize Shadowsocks share links according to SIP002."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from urllib.parse import quote, unquote_to_bytes, urlsplit
|
||
|
||
import re
|
||
import base64
|
||
import binascii
|
||
import ipaddress
|
||
|
||
__all__ = [
|
||
'SHADOWSOCKS_PLUGIN_METADATA_KEY',
|
||
'ShadowsocksURIData',
|
||
'ShadowsocksURIError',
|
||
'parseShadowsocksURI',
|
||
'serializeShadowsocksURI',
|
||
# Begin export for testing
|
||
'formatPluginArgument',
|
||
# End export for testing
|
||
]
|
||
|
||
SHADOWSOCKS_PLUGIN_METADATA_KEY = 'shadowsocksPlugin'
|
||
|
||
# Methods implemented by common Shadowsocks clients, including the legacy
|
||
# stream ciphers used by existing share links. Keeping validation here
|
||
# prevents malformed method names from becoming runtime configurations.
|
||
SUPPORTED_SHADOWSOCKS_METHODS = frozenset(
|
||
{
|
||
'none',
|
||
'plain',
|
||
'table',
|
||
'rc4',
|
||
'rc4-md5',
|
||
'aes-128-cfb',
|
||
'aes-192-cfb',
|
||
'aes-256-cfb',
|
||
'aes-128-ctr',
|
||
'aes-192-ctr',
|
||
'aes-256-ctr',
|
||
'bf-cfb',
|
||
'camellia-128-cfb',
|
||
'camellia-192-cfb',
|
||
'camellia-256-cfb',
|
||
'cast5-cfb',
|
||
'des-cfb',
|
||
'idea-cfb',
|
||
'rc2-cfb',
|
||
'salsa20',
|
||
'chacha20',
|
||
'chacha20-ietf',
|
||
'aes-128-gcm',
|
||
'aes-192-gcm',
|
||
'aes-256-gcm',
|
||
'chacha20-poly1305',
|
||
'chacha20-ietf-poly1305',
|
||
'xchacha20-poly1305',
|
||
'xchacha20-ietf-poly1305',
|
||
'2022-blake3-aes-128-gcm',
|
||
'2022-blake3-aes-256-gcm',
|
||
'2022-blake3-chacha20-poly1305',
|
||
}
|
||
)
|
||
|
||
_BASE64URL_PATTERN = re.compile(r'^[A-Za-z0-9_-]+={0,2}$')
|
||
_HEX_DIGITS = frozenset('0123456789abcdefABCDEF')
|
||
_PLUGIN_ESCAPED_CHARACTERS = frozenset('\\:;=')
|
||
|
||
|
||
class ShadowsocksURIError(ValueError):
|
||
"""Report a malformed or unsupported Shadowsocks share link."""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ShadowsocksURIData:
|
||
"""Store the meaningful fields of one SIP002 URI."""
|
||
|
||
method: str
|
||
password: str
|
||
host: str
|
||
port: int
|
||
plugin: str = ''
|
||
tag: str = ''
|
||
|
||
|
||
def _validatePercentEncoding(value: str, label: str):
|
||
"""Reject incomplete percent escapes before decoding *value*."""
|
||
index = 0
|
||
|
||
while index < len(value):
|
||
if value[index] != '%':
|
||
index += 1
|
||
continue
|
||
|
||
if (
|
||
index + 2 >= len(value)
|
||
or value[index + 1] not in _HEX_DIGITS
|
||
or value[index + 2] not in _HEX_DIGITS
|
||
):
|
||
raise ShadowsocksURIError(f'{label} contains invalid percent encoding')
|
||
|
||
index += 3
|
||
|
||
|
||
def _percentDecode(value: str, label: str) -> str:
|
||
"""Decode one RFC3986 component as strict UTF-8 without ``+`` semantics."""
|
||
_validatePercentEncoding(value, label)
|
||
|
||
try:
|
||
return unquote_to_bytes(value).decode('utf-8')
|
||
except UnicodeDecodeError as ex:
|
||
raise ShadowsocksURIError(f'{label} is not valid UTF-8') from ex
|
||
|
||
|
||
def _validateMethodAndPassword(method: str, password: str):
|
||
"""Validate fields before constructing a configuration."""
|
||
if method not in SUPPORTED_SHADOWSOCKS_METHODS:
|
||
raise ShadowsocksURIError(f'unsupported Shadowsocks method {method!r}')
|
||
|
||
if not password:
|
||
raise ShadowsocksURIError('Shadowsocks password cannot be empty')
|
||
|
||
|
||
def _decodeBase64URL(value: str) -> str:
|
||
"""Decode padded or unpadded Base64URL using strict alphabet validation."""
|
||
if not value or not _BASE64URL_PATTERN.fullmatch(value):
|
||
raise ShadowsocksURIError('userinfo is not valid Base64URL')
|
||
|
||
unpadded = value.rstrip('=')
|
||
suppliedPadding = len(value) - len(unpadded)
|
||
requiredPadding = -len(unpadded) % 4
|
||
|
||
if (
|
||
'=' in unpadded
|
||
or len(unpadded) % 4 == 1
|
||
or (suppliedPadding and suppliedPadding != requiredPadding)
|
||
):
|
||
raise ShadowsocksURIError('userinfo has invalid Base64URL padding')
|
||
|
||
padded = unpadded + '=' * requiredPadding
|
||
|
||
try:
|
||
decoded = base64.b64decode(
|
||
padded.encode('ascii'), altchars=b'-_', validate=True
|
||
)
|
||
|
||
return decoded.decode('utf-8')
|
||
except (binascii.Error, UnicodeDecodeError) as ex:
|
||
raise ShadowsocksURIError('userinfo is not valid Base64URL UTF-8') from ex
|
||
|
||
|
||
def _decodeLegacyBase64(value: str) -> str:
|
||
"""Decode a pre-SIP002 whole-authority payload for import compatibility."""
|
||
value = _percentDecode(value, 'legacy payload').rstrip('=')
|
||
|
||
if not value or len(value) % 4 == 1:
|
||
raise ShadowsocksURIError('legacy payload has invalid Base64 padding')
|
||
|
||
padded = value + '=' * (-len(value) % 4)
|
||
|
||
try:
|
||
decoded = base64.b64decode(
|
||
padded.encode('ascii'), altchars=b'-_', validate=True
|
||
)
|
||
|
||
return decoded.decode('utf-8')
|
||
except (ValueError, binascii.Error, UnicodeDecodeError) as ex:
|
||
raise ShadowsocksURIError('legacy payload is not valid Base64 UTF-8') from ex
|
||
|
||
|
||
def _parseEndpoint(value: str) -> tuple[str, int]:
|
||
"""Parse a normal URI host/port authority, including bracketed IPv6."""
|
||
try:
|
||
result = urlsplit(f'//{value}')
|
||
host = result.hostname or ''
|
||
port = result.port
|
||
except ValueError as ex:
|
||
raise ShadowsocksURIError('server endpoint has an invalid port') from ex
|
||
|
||
if not host or port is None:
|
||
raise ShadowsocksURIError('server endpoint must contain a host and port')
|
||
|
||
if not 1 <= port <= 65535:
|
||
raise ShadowsocksURIError('server port must be between 1 and 65535')
|
||
|
||
if result.username is not None or result.password is not None or result.path:
|
||
raise ShadowsocksURIError('server endpoint is malformed')
|
||
|
||
if any(character.isspace() or ord(character) < 32 for character in host):
|
||
raise ShadowsocksURIError('server host contains invalid characters')
|
||
|
||
return host, port
|
||
|
||
|
||
def _parsePlainUserinfo(value: str) -> tuple[str, str]:
|
||
"""Decode SIP002 plain method/password fields."""
|
||
if ':' not in value:
|
||
raise ShadowsocksURIError('plain userinfo is missing the method separator')
|
||
|
||
encodedMethod, encodedPassword = value.split(':', 1)
|
||
method = _percentDecode(encodedMethod, 'method')
|
||
password = _percentDecode(encodedPassword, 'password')
|
||
|
||
_validateMethodAndPassword(method, password)
|
||
|
||
return method, password
|
||
|
||
|
||
def _parseUserinfo(value: str) -> tuple[str, str]:
|
||
"""Parse SIP002 userinfo and one widespread encoded-separator variant."""
|
||
if not value or '@' in value:
|
||
raise ShadowsocksURIError('userinfo is malformed')
|
||
|
||
if ':' in value:
|
||
return _parsePlainUserinfo(value)
|
||
|
||
try:
|
||
decoded = _decodeBase64URL(value)
|
||
except ShadowsocksURIError as base64Error:
|
||
# Some producers percent-encode the entire ``method:password`` value,
|
||
# including the separator. It is not canonical SIP002, but accepting
|
||
# it preserves established import compatibility; export normalizes it.
|
||
decoded = _percentDecode(value, 'userinfo')
|
||
|
||
if ':' not in decoded:
|
||
raise base64Error
|
||
|
||
method, password = decoded.split(':', 1)
|
||
|
||
_validateMethodAndPassword(method, password)
|
||
|
||
if method.startswith('2022-'):
|
||
raise ShadowsocksURIError(
|
||
'AEAD-2022 userinfo must use a literal method separator'
|
||
)
|
||
|
||
return method, password
|
||
|
||
if ':' not in decoded:
|
||
raise ShadowsocksURIError('Base64URL userinfo is missing the method separator')
|
||
|
||
method, password = decoded.split(':', 1)
|
||
|
||
_validateMethodAndPassword(method, password)
|
||
|
||
if method.startswith('2022-'):
|
||
raise ShadowsocksURIError('AEAD-2022 userinfo must not be Base64URL encoded')
|
||
|
||
return method, password
|
||
|
||
|
||
def _splitEscaped(value: str, separator: str) -> list[str]:
|
||
"""Split a SIP003 plugin argument on an unescaped separator."""
|
||
result = []
|
||
current = []
|
||
escaped = False
|
||
|
||
for character in value:
|
||
if escaped:
|
||
if character not in _PLUGIN_ESCAPED_CHARACTERS:
|
||
raise ShadowsocksURIError(
|
||
f'plugin argument contains invalid escape \\{character}'
|
||
)
|
||
|
||
if character == separator:
|
||
current.append(character)
|
||
else:
|
||
# Preserve escapes that belong to a later parsing layer. In
|
||
# particular, an escaped equals sign must not become the
|
||
# option's key/value delimiter after splitting semicolons.
|
||
current.extend(('\\', character))
|
||
escaped = False
|
||
elif character == '\\':
|
||
escaped = True
|
||
elif character == separator:
|
||
result.append(''.join(current))
|
||
current = []
|
||
else:
|
||
current.append(character)
|
||
|
||
if escaped:
|
||
raise ShadowsocksURIError('plugin argument ends with an incomplete escape')
|
||
|
||
result.append(''.join(current))
|
||
|
||
return result
|
||
|
||
|
||
def _splitPluginOption(value: str) -> tuple[str, str | None]:
|
||
"""Split and unescape one plugin option at its first unescaped equals."""
|
||
parts = _splitEscaped(value, '=')
|
||
|
||
if len(parts) > 2:
|
||
parts[1:] = ['='.join(parts[1:])]
|
||
|
||
return (
|
||
_unescapePluginComponent(parts[0]),
|
||
_unescapePluginComponent(parts[1]) if len(parts) == 2 else None,
|
||
)
|
||
|
||
|
||
def _unescapePluginComponent(value: str) -> str:
|
||
"""Remove validated SIP003 escapes from one component."""
|
||
result = []
|
||
escaped = False
|
||
|
||
for character in value:
|
||
if escaped:
|
||
if character not in _PLUGIN_ESCAPED_CHARACTERS:
|
||
raise ShadowsocksURIError(
|
||
f'plugin argument contains invalid escape \\{character}'
|
||
)
|
||
|
||
result.append(character)
|
||
escaped = False
|
||
elif character == '\\':
|
||
escaped = True
|
||
else:
|
||
result.append(character)
|
||
|
||
if escaped:
|
||
raise ShadowsocksURIError('plugin argument ends with an incomplete escape')
|
||
|
||
return ''.join(result)
|
||
|
||
|
||
def escapePluginComponent(value: str) -> str:
|
||
"""Escape one SIP003 plugin name, option name, or option value."""
|
||
return ''.join(
|
||
f'\\{character}' if character in _PLUGIN_ESCAPED_CHARACTERS else character
|
||
for character in str(value)
|
||
)
|
||
|
||
|
||
def parsePluginArgument(value: str) -> tuple[str, tuple[tuple[str, str | None], ...]]:
|
||
"""Parse one decoded SIP002 plugin argument into semantic components."""
|
||
fields = _splitEscaped(value, ';')
|
||
name = _unescapePluginComponent(fields[0])
|
||
|
||
if not name:
|
||
raise ShadowsocksURIError('plugin name cannot be empty')
|
||
|
||
options = []
|
||
|
||
for field in fields[1:]:
|
||
if not field:
|
||
raise ShadowsocksURIError('plugin option cannot be empty')
|
||
|
||
key, optionValue = _splitPluginOption(field)
|
||
|
||
if not key:
|
||
raise ShadowsocksURIError('plugin option name cannot be empty')
|
||
|
||
options.append((key, optionValue))
|
||
|
||
return name, tuple(options)
|
||
|
||
|
||
def formatPluginArgument(
|
||
name: str, options: tuple[tuple[str, str | None], ...] = tuple()
|
||
) -> str:
|
||
"""Serialize semantic plugin components with SIP003 escaping."""
|
||
if not name:
|
||
raise ShadowsocksURIError('plugin name cannot be empty')
|
||
|
||
fields = [escapePluginComponent(name)]
|
||
|
||
for key, value in options:
|
||
if not key:
|
||
raise ShadowsocksURIError('plugin option name cannot be empty')
|
||
|
||
field = escapePluginComponent(key)
|
||
|
||
if value is not None:
|
||
field += f'={escapePluginComponent(value)}'
|
||
|
||
fields.append(field)
|
||
|
||
return ';'.join(fields)
|
||
|
||
|
||
def _normalizePluginArgument(value: str) -> str:
|
||
"""Validate and canonically re-escape one decoded plugin argument."""
|
||
name, options = parsePluginArgument(value)
|
||
|
||
return formatPluginArgument(name, options)
|
||
|
||
|
||
def _parseQuery(value: str) -> str:
|
||
"""Return the first plugin query value and ignore unsupported parameters."""
|
||
for field in value.split('&') if value else tuple():
|
||
encodedKey, separator, encodedValue = field.partition('=')
|
||
|
||
try:
|
||
key = _percentDecode(encodedKey, 'query parameter name')
|
||
except ShadowsocksURIError:
|
||
# An unsupported malformed parameter must not invalidate a valid
|
||
# Shadowsocks URI.
|
||
continue
|
||
|
||
if key != 'plugin':
|
||
continue
|
||
|
||
if not separator:
|
||
raise ShadowsocksURIError('plugin query parameter has no value')
|
||
|
||
return _normalizePluginArgument(
|
||
_percentDecode(encodedValue, 'plugin query parameter')
|
||
)
|
||
|
||
return ''
|
||
|
||
|
||
def _parseLegacyURI(result) -> ShadowsocksURIData:
|
||
"""Parse the pre-SIP002 ``base64(method:password@host:port)`` form."""
|
||
# A standard Base64 payload may itself contain ``/``. ``urlsplit`` moves
|
||
# that suffix into ``path``, so joining the two components reconstructs the
|
||
# original payload without treating any Base64 character as URI structure.
|
||
payload = f'{result.netloc}{result.path}'
|
||
decoded = _decodeLegacyBase64(payload)
|
||
|
||
userinfo, separator, endpoint = decoded.rpartition('@')
|
||
|
||
if not separator:
|
||
raise ShadowsocksURIError('legacy payload has no server separator')
|
||
|
||
method, password = _parsePlainUserinfo(userinfo)
|
||
host, port = _parseEndpoint(endpoint)
|
||
tag = _percentDecode(result.fragment, 'tag')
|
||
|
||
return ShadowsocksURIData(method, password, host, port, tag=tag)
|
||
|
||
|
||
def parseShadowsocksURI(uri: str) -> ShadowsocksURIData:
|
||
"""Parse a SIP002 URI, with isolated pre-SIP002 import compatibility."""
|
||
if not isinstance(uri, str) or not uri.strip():
|
||
raise ShadowsocksURIError('Shadowsocks URI cannot be empty')
|
||
|
||
uri = uri.strip()
|
||
|
||
try:
|
||
result = urlsplit(uri)
|
||
except ValueError as ex:
|
||
raise ShadowsocksURIError('Shadowsocks URI is malformed') from ex
|
||
|
||
if result.scheme.casefold() != 'ss':
|
||
raise ShadowsocksURIError('URI scheme must be ss')
|
||
|
||
if '@' not in result.netloc:
|
||
return _parseLegacyURI(result)
|
||
|
||
if result.path not in ('', '/'):
|
||
raise ShadowsocksURIError('SIP002 URI path must be empty or /')
|
||
|
||
userinfo, separator, endpoint = result.netloc.rpartition('@')
|
||
|
||
if not separator:
|
||
raise ShadowsocksURIError('SIP002 URI is missing userinfo')
|
||
|
||
method, password = _parseUserinfo(userinfo)
|
||
host, port = _parseEndpoint(endpoint)
|
||
plugin = _parseQuery(result.query)
|
||
tag = _percentDecode(result.fragment, 'tag')
|
||
|
||
return ShadowsocksURIData(method, password, host, port, plugin, tag)
|
||
|
||
|
||
def _formatHost(host: str) -> str:
|
||
"""Return a canonical URI host, bracketing IPv6 literals."""
|
||
host = str(host).strip()
|
||
|
||
if host.startswith('[') and host.endswith(']'):
|
||
host = host[1:-1]
|
||
|
||
if not host:
|
||
raise ShadowsocksURIError('server host cannot be empty')
|
||
|
||
try:
|
||
address = ipaddress.ip_address(host)
|
||
except ValueError:
|
||
if ':' in host or any(
|
||
character.isspace() or ord(character) < 32 for character in host
|
||
):
|
||
raise ShadowsocksURIError('server host is malformed')
|
||
|
||
try:
|
||
return host.encode('idna').decode('ascii')
|
||
except UnicodeError as ex:
|
||
raise ShadowsocksURIError('server host is malformed') from ex
|
||
|
||
return f'[{address.compressed}]' if address.version == 6 else address.compressed
|
||
|
||
|
||
def serializeShadowsocksURI(value: ShadowsocksURIData) -> str:
|
||
"""Serialize one configuration into canonical SIP002 form."""
|
||
if not isinstance(value, ShadowsocksURIData):
|
||
raise TypeError('value must be a ShadowsocksURIData instance')
|
||
|
||
_validateMethodAndPassword(value.method, value.password)
|
||
|
||
try:
|
||
port = int(value.port)
|
||
except (TypeError, ValueError) as ex:
|
||
raise ShadowsocksURIError('server port must be an integer') from ex
|
||
|
||
if not 1 <= port <= 65535:
|
||
raise ShadowsocksURIError('server port must be between 1 and 65535')
|
||
|
||
if value.method.startswith('2022-'):
|
||
userinfo = f'{quote(value.method, safe="")}:{quote(value.password, safe="")}'
|
||
else:
|
||
encoded = base64.urlsafe_b64encode(
|
||
f'{value.method}:{value.password}'.encode('utf-8')
|
||
).decode('ascii')
|
||
|
||
userinfo = encoded.rstrip('=')
|
||
|
||
endpoint = f'{_formatHost(value.host)}:{port}'
|
||
path, query = '', ''
|
||
|
||
if value.plugin:
|
||
plugin = _normalizePluginArgument(value.plugin)
|
||
path, query = '/', f'plugin={quote(plugin, safe="")}'
|
||
|
||
fragment = quote(value.tag, safe='')
|
||
|
||
return f'ss://{userinfo}@{endpoint}{path}{"?" + query if query else ""}{"#" + fragment if fragment else ""}'
|