// // main.swift // portalkit-cli // // Command-line interface for Portal TV pairing, TLS pinning, control, and MITM defense verification. // import Foundation import PortalKit /// Hybrid credential storage that reads/writes both macOS Keychain and ~/.portalkit/credentials.json final class CliCredentialStorage: CredentialStorage, @unchecked Sendable { private let keychain = KeychainCredentialStorage(accessGroup: nil) private let fileStorage = FileCredentialStorage() func getAuthToken() -> String? { if let token = keychain.getAuthToken(), !token.isEmpty { return token } return fileStorage.getAuthToken() } func getPinnedCertSha256() -> String? { if let cert = keychain.getPinnedCertSha256(), !cert.isEmpty { return cert } return fileStorage.getPinnedCertSha256() } func save(token: String, certSha256: String) { keychain.save(token: token, certSha256: certSha256) fileStorage.save(token: token, certSha256: certSha256) } func clear() { keychain.clear() fileStorage.clear() } } @main struct PortalKitCli { static let storage = CliCredentialStorage() static func printUsage() { let usage = """ PortalKit CLI - Portal TV pairing, TLS pinning, and control tool USAGE: portalkit-cli [options] COMMANDS: pair [--pin ] Initiates SRP-6a pairing over ephemeral TLS, prompts for (or uses) PIN, verifies server M2 proof, and pins the TLS certificate. status Checks Portal TV service status, inspects the presented TLS leaf certificate, and verifies certificate pinning state. control Sends control commands over pinned HTTPS. Mutations return {"ok":true}; live state is on GET /control/events (SSE). ({"mode":"…","config":{…}}); failures return {"error":"…","message":"…"}. Commands: state mode fixed?x=<0-1>&y=<0-1>&scale=<0.1-1> desk?tightness=<0-1> test-mitm Simulates a Man-in-the-Middle (MITM) attack with a substituted TLS certificate to verify that SRP-6a channel binding prevents unauthorized interception. OPTIONS: --pin, -p 6-digit PIN displayed on Portal TV (for 'pair' command) --help, -h Show this help reference EXAMPLES: portalkit-cli pair 10.0.0.10:5654 --pin 123456 portalkit-cli status 10.0.0.10:5654 portalkit-cli control 10.0.0.10:5654 state portalkit-cli control 10.0.0.10:5654 mode Desk portalkit-cli control 10.0.0.10:5654 fixed?x=0.5&y=0.5&scale=1.0 portalkit-cli control 10.0.0.10:5654 desk?tightness=0.5 portalkit-cli test-mitm 10.0.0.10:5654 """ print(usage) } static func main() async { var args = Array(CommandLine.arguments.dropFirst()) if args.isEmpty || args.contains("--help") || args.contains("-h") || args.first == "help" { printUsage() return } let command = args.removeFirst() switch command { case "pair": await handlePair(args: args) case "status": await handleStatus(args: args) case "control": await handleControl(args: args) case "test-mitm": await handleTestMitm(args: args) default: print("Unknown command: '\(command)'. Run with --help for usage.") exit(1) } } // MARK: - Command Handlers static func handlePair(args: [String]) async { guard let host = args.first, !host.hasPrefix("-") else { print("Error: Missing parameter.\nUsage: portalkit-cli pair [--pin ]") exit(1) } var pin: String? = nil var idx = 1 while idx < args.count { if (args[idx] == "--pin" || args[idx] == "-p"), idx + 1 < args.count { pin = args[idx + 1] idx += 2 } else { idx += 1 } } let client = PortalClient(host: host, credentialStorage: storage) do { print("Initiating SRP-6a pairing with Portal TV at \(client.host)…") let session = try await client.initiatePairing() print("Connected over ephemeral TLS.") print("Captured Server Leaf Cert SHA-256: \(session.capturedCertSha256Hex)") print("Pairing ID: \(session.pairingId)") if let exp = session.expiresIn { print("Session expires in: \(exp)s") } let effectivePin: String if let provided = pin, !provided.isEmpty { effectivePin = provided } else { print("Enter the 6-digit PIN displayed on Portal TV: ", terminator: "") fflush(stdout) effectivePin = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } guard !effectivePin.isEmpty else { print("Error: No PIN entered.") exit(1) } print("Computing channel-bound M1 proof…") print("Submitting M1 and verifying Portal TV M2 proof…") let result = try await client.completePairing(session: session, pin: effectivePin) print("\nPairing successful!") print("Server Proof M2 Verified: \(result.serverM2Hex.prefix(16))…") print("Bearer Token: \(result.token.prefix(12))…") print("TLS Certificate Pinned: \(result.pinnedCertSha256Hex)") print("Credentials saved successfully.") } catch { print("\nPairing failed: \(error.localizedDescription)") exit(1) } } static func handleStatus(args: [String]) async { guard let host = args.first, !host.hasPrefix("-") else { print("Error: Missing parameter.\nUsage: portalkit-cli status ") exit(1) } let client = PortalClient(host: host, credentialStorage: storage) print("Checking Portal TV status at \(client.host)…") do { let status = try await client.status() print("Host: \(status.host)") print("Service Online: \(status.isOnline ? "YES" : "NO")") print("Details: \(status.details)") if let srvSha = status.serverReportedCertSha256 { print("Server Cert Digest: \(srvSha)") } if let leafSha = status.leafCertSha256 { print("Leaf Cert SHA-256: \(leafSha)") } else { print("Leaf Cert SHA-256: [Not reached / no TLS cert]") } if let pinned = status.pinnedCertSha256 { print("Pinned Cert Digest: \(pinned)") print("Pin Status: \(status.isCertPinMatching ? "VALID (Match)" : "MISMATCH / INVALID")") } else { print("Pinned Cert Digest: [None configured]") } print("Paired: \(status.isPaired ? "YES" : "NO")") } catch { print("Status check failed: \(error.localizedDescription)") exit(1) } } static func handleControl(args: [String]) async { guard let host = args.first, !host.hasPrefix("-") else { print("Error: Missing parameter.\nUsage: portalkit-cli control ") exit(1) } let commandArgs = Array(args.dropFirst()) guard !commandArgs.isEmpty else { print("Error: Missing parameter.\nUsage: portalkit-cli control ") print("Examples:\n control mode Desk\n control fixed?x=0.5&y=0.5&scale=1.0") exit(1) } let fullCommand = commandArgs.joined(separator: " ") let client = PortalClient(host: host, credentialStorage: storage) print("Sending control command '\(fullCommand)' to \(client.host) via pinned HTTPS…") do { let response = try await client.control(command: fullCommand) print("Response: \(response)") } catch { print("Control command failed: \(error.localizedDescription)") exit(1) } } static func handleTestMitm(args: [String]) async { guard let host = args.first, !host.hasPrefix("-") else { print("Error: Missing parameter.\nUsage: portalkit-cli test-mitm ") exit(1) } let client = PortalClient(host: host, credentialStorage: storage) print("Testing SRP-6a TLS Channel Binding Defense against Portal TV at \(client.host)…") do { let result = try await client.testMitm() print("\n================== CHANNEL BINDING DEFENSE TEST ==================") print("Attack Simulation: \(result.attackDescription)") print("Genuine Server Cert SHA-256: \(result.realCertSha256Hex)") print("Simulated MITM Cert SHA-256: \(result.simulatedMitmCertSha256Hex)") print("Server Response: \(result.serverRejectionMessage)") if let remaining = result.attemptsRemaining { print("Pairing Attempts Left: \(remaining)") } print("------------------------------------------------------------------") if result.defenseSuccessful { print("Verdict: PASSED - Server successfully rejected the altered certificate binding!") print("Security Guarantee: An active MITM proxy cannot forge authentication without detection.") } else { print("Verdict: FAILED - Server unexpectedly accepted the connection!") exit(1) } print("==================================================================") } catch { print("Test failed to execute: \(error.localizedDescription)") exit(1) } } }