273 lines
10 KiB
Swift
273 lines
10 KiB
Swift
//
|
|
// 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 <command> [options]
|
|
|
|
COMMANDS:
|
|
pair <host> [--pin <pin>]
|
|
Initiates SRP-6a pairing over ephemeral TLS, prompts for (or uses) PIN,
|
|
verifies server M2 proof, and pins the TLS certificate.
|
|
|
|
status <host>
|
|
Checks Portal TV service status, inspects the presented TLS leaf certificate,
|
|
and verifies certificate pinning state.
|
|
|
|
control <host> <command>
|
|
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 <DefaultAuto|Desk|Meeting|Fixed>
|
|
fixed?x=<0-1>&y=<0-1>&scale=<0.1-1>
|
|
desk?tightness=<0-1>
|
|
|
|
test-mitm <host>
|
|
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 <pin> 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 <host> parameter.\nUsage: portalkit-cli pair <host> [--pin <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 <host> parameter.\nUsage: portalkit-cli status <host>")
|
|
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 <host> parameter.\nUsage: portalkit-cli control <host> <command>")
|
|
exit(1)
|
|
}
|
|
|
|
let commandArgs = Array(args.dropFirst())
|
|
guard !commandArgs.isEmpty else {
|
|
print("Error: Missing <command> parameter.\nUsage: portalkit-cli control <host> <command>")
|
|
print("Examples:\n control <host> mode Desk\n control <host> 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 <host> parameter.\nUsage: portalkit-cli test-mitm <host>")
|
|
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)
|
|
}
|
|
}
|
|
}
|