Init
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user