diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 943f02f8..c7486ca4 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -578,6 +578,28 @@ export interface GenHysteriaLinkInput { clientAuth: string; } +// Hysteria2's pinSHA256 must be a 64-char lowercase hex string — Xray-core +// clients hex-decode it and crash on a base64 value. The panel stores pins as +// base64 (xray-core's native TLS format / the generate button) or hex, either +// bare or colon-separated as `openssl x509 -fingerprint -sha256` emits it. Each +// entry is coerced to bare hex. Values that are neither a 32-byte hex nor a +// 32-byte base64 SHA-256 pass through unchanged. +function hysteriaPinHex(pin: string): string { + const stripped = pin.trim().replace(/:/g, ''); + if (/^[0-9a-fA-F]{64}$/.test(stripped)) return stripped.toLowerCase(); + try { + const binary = atob(pin.trim().replace(/-/g, '+').replace(/_/g, '/')); + if (binary.length !== 32) return pin; + let hex = ''; + for (let i = 0; i < binary.length; i++) { + hex += binary.charCodeAt(i).toString(16).padStart(2, '0'); + } + return hex; + } catch { + return pin; + } +} + // Hysteria share link: hysteria://@:?#. // The URL scheme is "hysteria2" when settings.version === 2 (hysteria v2 // AKA hysteria2), "hysteria" otherwise. Salamander obfuscation pulls its @@ -611,7 +633,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string { if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList); if (tls.serverName.length > 0) params.set('sni', tls.serverName); if (tls.settings.pinnedPeerCertSha256.length > 0) { - params.set('pinSHA256', tls.settings.pinnedPeerCertSha256.join(',')); + params.set('pinSHA256', tls.settings.pinnedPeerCertSha256.map(hysteriaPinHex).join(',')); } const udpMasks = stream.finalmask?.udp; diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index c75885bf..9e893544 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/frontend/src/test/inbound-link.test.ts @@ -158,6 +158,43 @@ describe('genHysteriaLink', () => { expect(link).toContain('mport=20000-50000'); expect(link.endsWith('#hop-test')).toBe(true); }); + + it('normalizes pinSHA256 to hex for base64, raw-hex and colon-hex pins (issue #4818)', () => { + const [, raw] = fixtures[0]; + const base64Pin = 'yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ='; + const hexPin = '84491c0312d9e70f519ce24659a2ca7d9c4ec59dc86417ece426945e0f939293'; + const colonPin = 'C8:47:DD:23:95:D0:97:8C:07:80:B8:20:1C:4B:28:9A:8B:28:15:97:D4:7C:27:5F:2D:77:D3:F9:6D:8D:E9:C4'; + const stream = raw.streamSettings as Record; + const tls = stream.tlsSettings as Record; + const tlsClientSettings = tls.settings as Record; + const withPins = { + ...raw, + streamSettings: { + ...stream, + tlsSettings: { + ...tls, + settings: { ...tlsClientSettings, pinnedPeerCertSha256: [base64Pin, hexPin, colonPin] }, + }, + }, + }; + const typed = InboundSchema.parse(withPins); + const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0]; + + const link = genHysteriaLink({ + inbound: typed, + address: 'example.test', + port: typed.port, + remark: 'pin-test', + clientAuth: client.auth, + }); + + const pin = new URL(link).searchParams.get('pinSHA256'); + expect(pin).toBe( + 'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4,' + + '84491c0312d9e70f519ce24659a2ca7d9c4ec59dc86417ece426945e0f939293,' + + 'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4', + ); + }); }); describe('genWireguardLink + genWireguardConfig', () => { diff --git a/sub/subService.go b/sub/subService.go index 09f45609..5bcd88e7 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -1,7 +1,9 @@ package sub import ( + "crypto/sha256" "encoding/base64" + "encoding/hex" "fmt" "maps" "net" @@ -609,6 +611,9 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin } } if pins, ok := pinnedSha256List(tlsSettings); ok { + for i, p := range pins { + pins[i] = hysteriaPinHex(p) + } params["pinSHA256"] = strings.Join(pins, ",") } } @@ -937,6 +942,36 @@ func pinnedSha256List(tlsClientSettings any) ([]string, bool) { return out, true } +// hysteriaPinHex normalises a pinnedPeerCertSha256 entry into the 64-character +// lowercase hex form that Xray-core's Hysteria2 pinSHA256 parser requires. +// +// The panel stores pins in several shapes: base64 (xray-core's native TLS +// format, used by the generate button and the JSON subscription) and hex — +// either bare or colon-separated as `openssl x509 -fingerprint -sha256` emits +// it. Hysteria2 clients hex-decode pinSHA256 and crash on a base64 value, so +// each entry is coerced to bare hex here. Anything that is neither a 32-byte +// hex nor a 32-byte base64 SHA-256 is returned unchanged so unexpected data is +// not silently dropped. Mirrors decodeCertPin in web/service/node.go. +func hysteriaPinHex(pin string) string { + pin = strings.TrimSpace(pin) + if h := strings.ReplaceAll(pin, ":", ""); len(h) == hex.EncodedLen(sha256.Size) { + if _, err := hex.DecodeString(h); err == nil { + return strings.ToLower(h) + } + } + for _, enc := range []*base64.Encoding{ + base64.StdEncoding, + base64.RawStdEncoding, + base64.URLEncoding, + base64.RawURLEncoding, + } { + if b, err := enc.DecodeString(pin); err == nil && len(b) == sha256.Size { + return hex.EncodeToString(b) + } + } + return pin +} + func applyShareRealityParams(stream map[string]any, params map[string]string) { params["security"] = "reality" realitySetting, _ := stream["realitySettings"].(map[string]any) diff --git a/sub/subService_test.go b/sub/subService_test.go index ea983392..21286327 100644 --- a/sub/subService_test.go +++ b/sub/subService_test.go @@ -780,6 +780,40 @@ func TestHasFinalMaskContent(t *testing.T) { } } +func TestHysteriaPinHex(t *testing.T) { + const hexPin = "c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4" + + cases := []struct { + name string + in string + want string + }{ + // Std base64 (xray-core's native TLS format / the panel generate button) + // must be re-encoded to the hex form Hysteria2 clients expect (#4818). + {"std base64", "yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ=", hexPin}, + // A manually pasted hex fingerprint passes through (lowercased). + {"hex passthrough", hexPin, hexPin}, + {"uppercase hex lowercased", strings.ToUpper(hexPin), hexPin}, + // openssl x509 -fingerprint -sha256 emits colon-separated hex. + {"colon hex stripped", "C8:47:DD:23:95:D0:97:8C:07:80:B8:20:1C:4B:28:9A:8B:28:15:97:D4:7C:27:5F:2D:77:D3:F9:6D:8D:E9:C4", hexPin}, + {"surrounding whitespace trimmed", " " + hexPin + " ", hexPin}, + // URL-safe base64 with the same 32 bytes decodes identically. + {"url-safe base64", "yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT-W2N6cQ=", hexPin}, + // Garbage that is neither valid hex nor a 32-byte base64 is left as-is + // rather than silently dropped. + {"unrecognized passthrough", "not-a-pin", "not-a-pin"}, + {"empty", "", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := hysteriaPinHex(tc.in); got != tc.want { + t.Fatalf("hysteriaPinHex(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + func TestHysteriaHopPorts(t *testing.T) { withHop := func(ports any) map[string]any { return map[string]any{