From d886a8a523bbdcf4c9e76174f5142333c2249312 Mon Sep 17 00:00:00 2001 From: ares Date: Sat, 12 Sep 2026 15:29:11 +0300 Subject: [PATCH] Add real iOS HTTPS and WSS certificate regression checks --- .github/workflows/ios.yml | 10 ++++ MusicBridge.xcodeproj/project.pbxproj | 26 +++++++-- MusicBridgeTests/TLSTests.swift | 36 +++++++++++++ MusicBridgeTests/tls-fixture.json | 1 + tools/generate_project.py | 5 +- tools/tls_fixture.py | 78 +++++++++++++++++++++++++++ 6 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 MusicBridgeTests/TLSTests.swift create mode 100644 MusicBridgeTests/tls-fixture.json create mode 100644 tools/tls_fixture.py diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 3eb2480..b07ccce 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -36,6 +36,15 @@ jobs: run: | set -o pipefail mkdir -p build + python3 -u tools/tls_fixture.py > build/tls-fixture.log 2>&1 & + TLS_PID=$! + trap 'kill "$TLS_PID" 2>/dev/null || true' EXIT + for attempt in {1..30}; do + if grep -q 'TLS fixtures ready' build/tls-fixture.log; then break; fi + if ! kill -0 "$TLS_PID" 2>/dev/null; then cat build/tls-fixture.log; exit 1; fi + sleep 1 + done + grep -q 'TLS fixtures ready' build/tls-fixture.log || { cat build/tls-fixture.log; exit 1; } xcodebuild test \ -project MusicBridge.xcodeproj \ -scheme MusicBridge \ @@ -53,6 +62,7 @@ jobs: name: ios-test-results path: | build/xcodebuild.log + build/tls-fixture.log build/MusicBridge.xcresult if-no-files-found: warn retention-days: 7 diff --git a/MusicBridge.xcodeproj/project.pbxproj b/MusicBridge.xcodeproj/project.pbxproj index e5af05a..1266a80 100644 --- a/MusicBridge.xcodeproj/project.pbxproj +++ b/MusicBridge.xcodeproj/project.pbxproj @@ -81,11 +81,21 @@ "isa" = "PBXBuildFile"; "fileRef" = "ACD0393DCDDDF044F2966340"; }; + "8BED860F1BD59C500D215E8D" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "TLSTests.swift"; + "sourceTree" = ""; + }; + "DC1A5B0B94F4C98F1CA36676" = { + "isa" = "PBXBuildFile"; + "fileRef" = "8BED860F1BD59C500D215E8D"; + }; "E1AD768E105BF42350625991" = { "isa" = "PBXGroup"; "path" = "MusicBridgeTests"; "sourceTree" = ""; - "children" = ("ACD0393DCDDDF044F2966340", "F16D05EC6B29248D2C61ADB1",); + "children" = ("ACD0393DCDDDF044F2966340", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",); }; "F16D05EC6B29248D2C61ADB1" = { "isa" = "PBXFileReference"; @@ -97,6 +107,16 @@ "isa" = "PBXBuildFile"; "fileRef" = "F16D05EC6B29248D2C61ADB1"; }; + "4731785CAB9BB914B8834AE6" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "text.json"; + "path" = "tls-fixture.json"; + "sourceTree" = ""; + }; + "7D079549A35384F9CAE8A5C4" = { + "isa" = "PBXBuildFile"; + "fileRef" = "4731785CAB9BB914B8834AE6"; + }; "7C35A0276A762371ABAE5596" = { "isa" = "PBXFileReference"; "explicitFileType" = "wrapper.application"; @@ -189,7 +209,7 @@ "AB453A3F0E1490D48374A69A" = { "isa" = "PBXSourcesBuildPhase"; "buildActionMask" = 2147483647; - "files" = ("17E248E58CB43C680B48DD51",); + "files" = ("17E248E58CB43C680B48DD51", "DC1A5B0B94F4C98F1CA36676",); "runOnlyForDeploymentPostprocessing" = 0; }; "9AA2A4C344B5C474AF5D336C" = { @@ -201,7 +221,7 @@ "5D92C2C610AA78B83DDF2133" = { "isa" = "PBXResourcesBuildPhase"; "buildActionMask" = 2147483647; - "files" = ("F8A2DE539EBA355A3807D729",); + "files" = ("F8A2DE539EBA355A3807D729", "7D079549A35384F9CAE8A5C4",); "runOnlyForDeploymentPostprocessing" = 0; }; "DAB01D73CBBCF1FEDE84E2D6" = { diff --git a/MusicBridgeTests/TLSTests.swift b/MusicBridgeTests/TLSTests.swift new file mode 100644 index 0000000..0f39bb5 --- /dev/null +++ b/MusicBridgeTests/TLSTests.swift @@ -0,0 +1,36 @@ +import XCTest +@testable import MusicBridge + +final class TLSTests: XCTestCase { + private func connection(_ name: String, wrongPin: Bool = false) throws -> Connection { + let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "tls-fixture", withExtension: "json")) + let fixtures = try JSONDecoder().decode([String: [String: String]].self, from: Data(contentsOf: url)) + guard let fixture = fixtures[name] else { throw XCTSkip("Start tools/tls_fixture.py before building TLS tests.") } + return try Connection(endpoint: XCTUnwrap(fixture["endpoint"]), + fingerprint: wrongPin ? String(repeating: "0", count: 64) : XCTUnwrap(fixture["fingerprint"])) + } + + func testPinnedSelfSignedHTTPSAndWSS() async throws { + let client = BridgeClient(try connection("valid")) + defer { client.close() } + let state = try await client.state() + XCTAssertEqual(state.protocolVersion, 1) + client.openEvents() + let event = try await client.nextState() + XCTAssertEqual(event.title, state.title) + } + + func testWrongFingerprintIsRejected() async throws { + let client = BridgeClient(try connection("valid", wrongPin: true)) + defer { client.close() } + do { _ = try await client.state(); XCTFail("Accepted the wrong fingerprint") } + catch { XCTAssertTrue(error is URLError) } + } + + func testExpiredPinnedCertificateIsRejected() async throws { + let client = BridgeClient(try connection("expired")) + defer { client.close() } + do { _ = try await client.state(); XCTFail("Accepted an expired certificate") } + catch { XCTAssertTrue(error is URLError) } + } +} diff --git a/MusicBridgeTests/tls-fixture.json b/MusicBridgeTests/tls-fixture.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/MusicBridgeTests/tls-fixture.json @@ -0,0 +1 @@ +{} diff --git a/tools/generate_project.py b/tools/generate_project.py index 7104f4c..44d46b1 100644 --- a/tools/generate_project.py +++ b/tools/generate_project.py @@ -39,6 +39,9 @@ test_group, test_sources = source_group("MusicBridgeTests") fixture = add("fixture", "PBXFileReference", lastKnownFileType="text.json", path="state-v1.json", sourceTree="") objects[test_group]["children"].append(fixture) fixture_build = add("fixture-build", "PBXBuildFile", fileRef=fixture) +tls_fixture = add("tls-fixture", "PBXFileReference", lastKnownFileType="text.json", path="tls-fixture.json", sourceTree="") +objects[test_group]["children"].append(tls_fixture) +tls_fixture_build = add("tls-fixture-build", "PBXBuildFile", fileRef=tls_fixture) products = [] targets = [] for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridgeTests", test_sources, True)]: @@ -47,7 +50,7 @@ for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridg products.append(product) phases = [add(name + "sources", "PBXSourcesBuildPhase", buildActionMask=2147483647, files=sources, runOnlyForDeploymentPostprocessing=0), add(name + "frameworks", "PBXFrameworksBuildPhase", buildActionMask=2147483647, files=[], runOnlyForDeploymentPostprocessing=0), - add(name + "resources", "PBXResourcesBuildPhase", buildActionMask=2147483647, files=[fixture_build] if is_test else [], runOnlyForDeploymentPostprocessing=0)] + add(name + "resources", "PBXResourcesBuildPhase", buildActionMask=2147483647, files=[fixture_build, tls_fixture_build] if is_test else [], runOnlyForDeploymentPostprocessing=0)] settings = dict(PRODUCT_NAME="$(TARGET_NAME)", PRODUCT_BUNDLE_IDENTIFIER="ru.yukinoki.musicbridge" + (".tests" if is_test else ""), SWIFT_VERSION="5.0", IPHONEOS_DEPLOYMENT_TARGET="17.0", SDKROOT="iphoneos", SUPPORTED_PLATFORMS="iphoneos iphonesimulator", TARGETED_DEVICE_FAMILY="1", CODE_SIGN_STYLE="Automatic", diff --git a/tools/tls_fixture.py b/tools/tls_fixture.py new file mode 100644 index 0000000..11293c9 --- /dev/null +++ b/tools/tls_fixture.py @@ -0,0 +1,78 @@ +"""CI-only HTTPS/WSS server with the same certificate shape as the Windows agent.""" +import base64 +import hashlib +import http.server +import ipaddress +import json +import pathlib +import socket +import ssl +import struct +import subprocess +import threading + +root = pathlib.Path(__file__).resolve().parents[1] +work = root / "build/tls" +work.mkdir(parents=True, exist_ok=True) +# Discover the runner's LAN address without sending a packet. +with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: + probe.connect(("192.0.2.1", 80)) + address = probe.getsockname()[0] +assert any(ipaddress.ip_address(address) in ipaddress.ip_network(n) + for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")), address +config = work / "cert.cnf" +config.write_text("""[req] +distinguished_name = dn +prompt = no +[dn] +CN = MusicBridge Agent +[ext] +basicConstraints = critical,CA:FALSE +keyUsage = critical,digitalSignature,keyEncipherment +extendedKeyUsage = serverAuth +subjectAltName = DNS:localhost,IP:127.0.0.1 +""") +state = json.loads((root / "MusicBridgeTests/state-v1.json").read_text()) + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/v1/events": + accept = base64.b64encode(hashlib.sha1( + (self.headers["Sec-WebSocket-Key"] + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()).decode() + self.send_response(101) + self.send_header("Upgrade", "websocket") + self.send_header("Connection", "Upgrade") + self.send_header("Sec-WebSocket-Accept", accept) + self.end_headers() + payload = json.dumps({"type": "state", "state": state}).encode() + self.wfile.write(b"\x81\x7e" + struct.pack("!H", len(payload)) + payload) + self.wfile.flush() + else: + payload = json.dumps(state).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +fixtures = {} +for name, days in (("valid", "1825"), ("expired", "-1")): + key, csr, cert = (work / (name + suffix) for suffix in (".key", ".csr", ".pem")) + subprocess.run(["openssl", "req", "-new", "-newkey", "rsa:2048", "-nodes", "-config", str(config), + "-keyout", str(key), "-out", str(csr)], check=True, capture_output=True) + subprocess.run(["openssl", "x509", "-req", "-in", str(csr), "-signkey", str(key), "-sha256", + "-days", days, "-extfile", str(config), "-extensions", "ext", "-out", str(cert)], + check=True, capture_output=True) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.load_cert_chain(cert, key) + server = http.server.ThreadingHTTPServer((address, 0), Handler) + server.socket = context.wrap_socket(server.socket, server_side=True) + threading.Thread(target=server.serve_forever, daemon=True).start() + fixtures[name] = {"endpoint": f"https://{address}:{server.server_port}", + "fingerprint": hashlib.sha256(ssl.PEM_cert_to_DER_cert(cert.read_text())).hexdigest()} +(root / "MusicBridgeTests/tls-fixture.json").write_text(json.dumps(fixtures)) +print("TLS fixtures ready", flush=True) +threading.Event().wait()