Add real iOS HTTPS and WSS certificate regression checks

This commit is contained in:
2026-09-12 15:29:11 +03:00
parent f5c44cb323
commit d886a8a523
6 changed files with 152 additions and 4 deletions
+10
View File
@@ -36,6 +36,15 @@ jobs:
run: | run: |
set -o pipefail set -o pipefail
mkdir -p build 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 \ xcodebuild test \
-project MusicBridge.xcodeproj \ -project MusicBridge.xcodeproj \
-scheme MusicBridge \ -scheme MusicBridge \
@@ -53,6 +62,7 @@ jobs:
name: ios-test-results name: ios-test-results
path: | path: |
build/xcodebuild.log build/xcodebuild.log
build/tls-fixture.log
build/MusicBridge.xcresult build/MusicBridge.xcresult
if-no-files-found: warn if-no-files-found: warn
retention-days: 7 retention-days: 7
+23 -3
View File
@@ -81,11 +81,21 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "ACD0393DCDDDF044F2966340"; "fileRef" = "ACD0393DCDDDF044F2966340";
}; };
"8BED860F1BD59C500D215E8D" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "TLSTests.swift";
"sourceTree" = "<group>";
};
"DC1A5B0B94F4C98F1CA36676" = {
"isa" = "PBXBuildFile";
"fileRef" = "8BED860F1BD59C500D215E8D";
};
"E1AD768E105BF42350625991" = { "E1AD768E105BF42350625991" = {
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"path" = "MusicBridgeTests"; "path" = "MusicBridgeTests";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"children" = ("ACD0393DCDDDF044F2966340", "F16D05EC6B29248D2C61ADB1",); "children" = ("ACD0393DCDDDF044F2966340", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",);
}; };
"F16D05EC6B29248D2C61ADB1" = { "F16D05EC6B29248D2C61ADB1" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
@@ -97,6 +107,16 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "F16D05EC6B29248D2C61ADB1"; "fileRef" = "F16D05EC6B29248D2C61ADB1";
}; };
"4731785CAB9BB914B8834AE6" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "text.json";
"path" = "tls-fixture.json";
"sourceTree" = "<group>";
};
"7D079549A35384F9CAE8A5C4" = {
"isa" = "PBXBuildFile";
"fileRef" = "4731785CAB9BB914B8834AE6";
};
"7C35A0276A762371ABAE5596" = { "7C35A0276A762371ABAE5596" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
"explicitFileType" = "wrapper.application"; "explicitFileType" = "wrapper.application";
@@ -189,7 +209,7 @@
"AB453A3F0E1490D48374A69A" = { "AB453A3F0E1490D48374A69A" = {
"isa" = "PBXSourcesBuildPhase"; "isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647; "buildActionMask" = 2147483647;
"files" = ("17E248E58CB43C680B48DD51",); "files" = ("17E248E58CB43C680B48DD51", "DC1A5B0B94F4C98F1CA36676",);
"runOnlyForDeploymentPostprocessing" = 0; "runOnlyForDeploymentPostprocessing" = 0;
}; };
"9AA2A4C344B5C474AF5D336C" = { "9AA2A4C344B5C474AF5D336C" = {
@@ -201,7 +221,7 @@
"5D92C2C610AA78B83DDF2133" = { "5D92C2C610AA78B83DDF2133" = {
"isa" = "PBXResourcesBuildPhase"; "isa" = "PBXResourcesBuildPhase";
"buildActionMask" = 2147483647; "buildActionMask" = 2147483647;
"files" = ("F8A2DE539EBA355A3807D729",); "files" = ("F8A2DE539EBA355A3807D729", "7D079549A35384F9CAE8A5C4",);
"runOnlyForDeploymentPostprocessing" = 0; "runOnlyForDeploymentPostprocessing" = 0;
}; };
"DAB01D73CBBCF1FEDE84E2D6" = { "DAB01D73CBBCF1FEDE84E2D6" = {
+36
View File
@@ -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) }
}
}
+1
View File
@@ -0,0 +1 @@
{}
+4 -1
View File
@@ -39,6 +39,9 @@ test_group, test_sources = source_group("MusicBridgeTests")
fixture = add("fixture", "PBXFileReference", lastKnownFileType="text.json", path="state-v1.json", sourceTree="<group>") fixture = add("fixture", "PBXFileReference", lastKnownFileType="text.json", path="state-v1.json", sourceTree="<group>")
objects[test_group]["children"].append(fixture) objects[test_group]["children"].append(fixture)
fixture_build = add("fixture-build", "PBXBuildFile", fileRef=fixture) fixture_build = add("fixture-build", "PBXBuildFile", fileRef=fixture)
tls_fixture = add("tls-fixture", "PBXFileReference", lastKnownFileType="text.json", path="tls-fixture.json", sourceTree="<group>")
objects[test_group]["children"].append(tls_fixture)
tls_fixture_build = add("tls-fixture-build", "PBXBuildFile", fileRef=tls_fixture)
products = [] products = []
targets = [] targets = []
for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridgeTests", test_sources, True)]: 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) products.append(product)
phases = [add(name + "sources", "PBXSourcesBuildPhase", buildActionMask=2147483647, files=sources, runOnlyForDeploymentPostprocessing=0), phases = [add(name + "sources", "PBXSourcesBuildPhase", buildActionMask=2147483647, files=sources, runOnlyForDeploymentPostprocessing=0),
add(name + "frameworks", "PBXFrameworksBuildPhase", buildActionMask=2147483647, files=[], 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 ""), 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", SWIFT_VERSION="5.0", IPHONEOS_DEPLOYMENT_TARGET="17.0", SDKROOT="iphoneos",
SUPPORTED_PLATFORMS="iphoneos iphonesimulator", TARGETED_DEVICE_FAMILY="1", CODE_SIGN_STYLE="Automatic", SUPPORTED_PLATFORMS="iphoneos iphonesimulator", TARGETED_DEVICE_FAMILY="1", CODE_SIGN_STYLE="Automatic",
+78
View File
@@ -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()