Files
Portal-Camera/mac/PortalKit/Sources/portalkit-cli/main.swift
T
2026-09-13 12:15:36 -07:00

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)
}
}
}