Add real iOS HTTPS and WSS certificate regression checks
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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" = {
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user