Files
2026-08-17 15:42:59 +08:00

546 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (C) 2024present 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 ""}'