diff --git a/.gitignore b/.gitignore index 06e7890..e6dce20 100644 --- a/.gitignore +++ b/.gitignore @@ -330,3 +330,4 @@ setup_logs/ /research/artifacts /research/xnu /vm +/.tmp diff --git a/Package.swift b/Package.swift index 989070c..e974f1b 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,9 @@ let package = Package( .package(path: "vendor/libimg4-spm"), .package(path: "vendor/MachOKit"), .package(path: "vendor/AppleMobileDeviceLibrary"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"), + .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0"..<"4.0.0"), + .package(path: "vendor/swift-nio-ssh"), .package(path: "vendor/swift-subprocess"), .package(path: "vendor/swift-trustcache"), .package(path: "vendor/SWCompression"), @@ -60,8 +63,12 @@ let package = Package( dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Capstone", package: "libcapstone-spm"), + .product(name: "Crypto", package: "swift-crypto"), .product(name: "Dynamic", package: "Dynamic"), .product(name: "libirecovery", package: "AppleMobileDeviceLibrary"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOSSH", package: "swift-nio-ssh"), .product(name: "SWCompression", package: "SWCompression"), .product(name: "Subprocess", package: "swift-subprocess"), .product(name: "TrustCache", package: "swift-trustcache"), diff --git a/sources/vphone-cli/VPhoneCFWInstallCLI.swift b/sources/vphone-cli/VPhoneCFWInstallCLI.swift index 9c20a8b..3ba17a1 100644 --- a/sources/vphone-cli/VPhoneCFWInstallCLI.swift +++ b/sources/vphone-cli/VPhoneCFWInstallCLI.swift @@ -26,15 +26,16 @@ struct CFWInstallCLI: AsyncParsableCommand { var sshPort: Int = Int(ProcessInfo.processInfo.environment["SSH_PORT"] ?? "2222") ?? 2222 @Flag(help: "Skip halting the ramdisk after install") - var skipHalt: Bool = ProcessInfo.processInfo.environment["CFW_SKIP_HALT"] == "1" + var skipHalt = false mutating func run() async throws { + let effectiveSkipHalt = skipHalt || ProcessInfo.processInfo.environment["CFW_SKIP_HALT"] == "1" let installer = try VPhoneCFWInstaller( vmDirectory: vmDirectory.standardizedFileURL, projectRoot: projectRoot.standardizedFileURL, variant: variant, sshPort: sshPort, - skipHalt: skipHalt + skipHalt: effectiveSkipHalt ) try await installer.run() } diff --git a/sources/vphone-cli/VPhoneSSH.swift b/sources/vphone-cli/VPhoneSSH.swift index ae8a719..b968c80 100644 --- a/sources/vphone-cli/VPhoneSSH.swift +++ b/sources/vphone-cli/VPhoneSSH.swift @@ -1,4 +1,7 @@ import Foundation +import NIOCore +import NIOPosix +import NIOSSH struct VPhoneSSHCommandResult { let exitStatus: Int32 @@ -16,14 +19,20 @@ struct VPhoneSSHCommandResult { enum VPhoneSSHError: Error, CustomStringConvertible { case notConnected + case invalidChannelType case commandFailed(String) + case timeout(String) var description: String { switch self { case .notConnected: return "SSH client is not connected" + case .invalidChannelType: + return "Invalid SSH channel type" case let .commandFailed(message): return message + case let .timeout(context): + return "SSH timeout: \(context)" } } } @@ -34,8 +43,13 @@ final class VPhoneSSHClient: @unchecked Sendable { let username: String let password: String - private var connected = false - private var controlPath: URL? + private let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + private var channel: Channel? + private var sshHandler: NIOSSHHandler? + private var shutDown = false + private var debugEnabled: Bool { + ProcessInfo.processInfo.environment["VPHONE_SSH_DEBUG"] == "1" + } init(host: String, port: Int, username: String, password: String) { self.host = host @@ -44,48 +58,132 @@ final class VPhoneSSHClient: @unchecked Sendable { self.password = password } + deinit { + try? shutdown() + } + func connect() throws { - if connected { return } - let token = String(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(12)) - let socketPath = URL(fileURLWithPath: "/tmp/vssh.\(token).sock") - do { - let result = try runProcess( - executable: "/usr/bin/ssh", - arguments: masterArguments(controlPath: socketPath), - stdin: nil - ) - guard result.exitStatus == 0 else { - throw VPhoneSSHError.commandFailed(result.standardErrorString) + guard channel == nil else { return } + let connectionState = VPhoneSSHConnectionState(eventLoop: group.next()) + debug("connect begin host=\(host) port=\(port) user=\(username)") + + let bootstrap = ClientBootstrap(group: group) + .channelInitializer { [username, password] channel in + let configuration = SSHClientConfiguration( + userAuthDelegate: SimplePasswordDelegate(username: username, password: password), + serverAuthDelegate: AcceptAllHostKeysDelegate(), + transportProtectionSchemes: [ + AES256CTRHMACSHA256TransportProtection.self, + AES128CTRHMACSHA256TransportProtection.self, + AES256CTRHMACSHA1TransportProtection.self, + AES128CTRHMACSHA1TransportProtection.self, + ] + ) + let ssh = NIOSSHHandler( + role: .client(configuration), + allocator: channel.allocator, + inboundChildChannelInitializer: nil + ) + connectionState.sshHandler = ssh + return channel.pipeline.addHandler(ssh).flatMap { + channel.pipeline.addHandler(VPhoneSSHConnectionHandler(readyPromise: connectionState.readyPromise)) + }.flatMap { + channel.pipeline.addHandler(VPhoneSSHErrorHandler()) + } } - controlPath = socketPath - connected = true - } catch { - try? FileManager.default.removeItem(at: socketPath) - throw error + .connectTimeout(.seconds(5)) + .channelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1) + .channelOption(ChannelOptions.socket(SocketOptionLevel(IPPROTO_TCP), TCP_NODELAY), value: 1) + + let connectedChannel = try waitForFuture( + bootstrap.connect(host: host, port: port), + timeout: .seconds(8), + context: "tcp connect to \(host):\(port)" + ) + debug("tcp connected") + channel = connectedChannel + _ = try waitForFuture( + connectionState.readyPromise.futureResult, + timeout: .seconds(8), + context: "SSH authentication" + ) + debug("ssh authenticated") + guard let sshHandler = connectionState.sshHandler else { + throw VPhoneSSHError.notConnected } + self.sshHandler = sshHandler } func shutdown() throws { - if let controlPath { - _ = try? runProcess( - executable: "/usr/bin/ssh", - arguments: controlArguments(controlPath: controlPath) + ["-O", "exit", "\(username)@\(host)"], - stdin: nil - ) + guard !shutDown else { return } + shutDown = true + if let channel { + try? channel.close().wait() + self.channel = nil + self.sshHandler = nil } - if let controlPath { - try? FileManager.default.removeItem(at: controlPath) - } - controlPath = nil - connected = false + try group.syncShutdownGracefully() } func execute(_ command: String, stdin: Data? = nil, requireSuccess: Bool = true) throws -> VPhoneSSHCommandResult { - guard connected else { + guard let channel, let sshHandler else { throw VPhoneSSHError.notConnected } + debug("execute command=\(command)") - let result = try runSSH(command: command, stdin: stdin) + let resultBox = VPhoneSSHCommandResultBox(eventLoop: channel.eventLoop) + let childPromise = channel.eventLoop.makePromise(of: Channel.self) + let createChannelPromise = channel.eventLoop.makePromise(of: Void.self) + let commandTimeout: TimeAmount + if let stdin, stdin.count > 1_000_000 { + commandTimeout = .seconds(600) + } else if stdin != nil { + commandTimeout = .seconds(120) + } else { + commandTimeout = .seconds(30) + } + channel.eventLoop.execute { + sshHandler.createChannel(childPromise) { childChannel, channelType in + guard channelType == .session else { + return channel.eventLoop.makeFailedFuture(VPhoneSSHError.invalidChannelType) + } + + return childChannel.pipeline.addHandler( + VPhoneSSHExecHandler(command: command, stdinData: stdin, resultBox: resultBox) + ) + } + createChannelPromise.succeed(()) + } + + childPromise.futureResult.whenFailure { error in + resultBox.fail(error) + } + + let result: VPhoneSSHCommandResult + do { + _ = try waitForFuture( + createChannelPromise.futureResult, + timeout: .seconds(2), + context: "schedule exec channel open" + ) + debug("exec channel scheduled") + let childChannel = try waitForFuture( + childPromise.futureResult, + timeout: .seconds(8), + context: "open exec channel" + ) + debug("exec channel active") + result = try waitForFuture( + resultBox.futureResult, + timeout: commandTimeout, + context: "command result" + ) + debug("command result exit=\(result.exitStatus)") + childChannel.close(promise: nil) + } catch { + resultBox.fail(error) + throw error + } if requireSuccess, result.exitStatus != 0 { let stderr = result.standardErrorString.trimmingCharacters(in: .whitespacesAndNewlines) throw VPhoneSSHError.commandFailed( @@ -143,109 +241,6 @@ final class VPhoneSSHClient: @unchecked Sendable { } } - private func runSSH(command: String, stdin: Data?) throws -> VPhoneSSHCommandResult { - let result = try runProcess( - executable: "/usr/bin/ssh", - arguments: sshArguments(command: command), - stdin: stdin - ) - return VPhoneSSHCommandResult( - exitStatus: result.exitStatus, - standardOutput: result.standardOutput, - standardError: result.standardError - ) - } - - private func sshArguments(command: String) -> [String] { - var arguments = sshBaseArguments() - if let controlPath { - arguments.append(contentsOf: controlArguments(controlPath: controlPath)) - } - arguments.append(contentsOf: [ - "-p", "\(port)", - "\(username)@\(host)", - command, - ]) - return arguments - } - - private func masterArguments(controlPath: URL) -> [String] { - var arguments = sshBaseArguments() - arguments.append(contentsOf: [ - "-M", - "-S", controlPath.path, - "-o", "ControlMaster=yes", - "-o", "ControlPersist=yes", - "-p", "\(port)", - "-f", - "-N", - "\(username)@\(host)", - ]) - return arguments - } - - private func sshBaseArguments() -> [String] { - [ - "-F", "/dev/null", - "-T", - "-o", "LogLevel=ERROR", - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "GlobalKnownHostsFile=/dev/null", - "-o", "UpdateHostKeys=no", - "-o", "PreferredAuthentications=password,keyboard-interactive", - "-o", "PubkeyAuthentication=no", - "-o", "NumberOfPasswordPrompts=1", - "-o", "ConnectTimeout=5", - ] - } - - private func controlArguments(controlPath: URL) -> [String] { - ["-S", controlPath.path, "-o", "ControlMaster=no"] - } - - private func runProcess(executable: String, arguments: [String], stdin: Data?) throws -> VPhoneSSHCommandResult { - let process = Process() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = arguments - - var environment = ProcessInfo.processInfo.environment - for (key, value) in VPhoneHost.sshAskpassEnvironment(password: password) { - if let value { - environment[key] = value - } else { - environment.removeValue(forKey: key) - } - } - process.environment = environment - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - if let stdin, !stdin.isEmpty { - let stdinPipe = Pipe() - process.standardInput = stdinPipe - try process.run() - stdinPipe.fileHandleForWriting.write(stdin) - try stdinPipe.fileHandleForWriting.close() - } else { - process.standardInput = FileHandle.nullDevice - try process.run() - } - - let stdout = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let stderr = stderrPipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - - return VPhoneSSHCommandResult( - exitStatus: process.terminationStatus, - standardOutput: stdout, - standardError: stderr - ) - } - private func shellQuote(_ string: String) -> String { "'" + string.replacingOccurrences(of: "'", with: "'\\''") + "'" } @@ -312,4 +307,264 @@ final class VPhoneSSHClient: @unchecked Sendable { } return remotePath } + + private func waitForFuture( + _ future: EventLoopFuture, + timeout: TimeAmount, + context: String + ) throws -> Value { + let promise = future.eventLoop.makePromise(of: Value.self) + let scheduled = future.eventLoop.scheduleTask(in: timeout) { + promise.fail(VPhoneSSHError.timeout(context)) + } + future.whenComplete { result in + scheduled.cancel() + switch result { + case let .success(value): + promise.succeed(value) + case let .failure(error): + promise.fail(error) + } + } + return try promise.futureResult.wait() + } + + private func debug(_ message: String) { + guard debugEnabled else { return } + FileHandle.standardError.write(Data("[ssh] \(message)\n".utf8)) + } +} + +private final class VPhoneSSHExecHandler: ChannelDuplexHandler, @unchecked Sendable { + typealias InboundIn = SSHChannelData + typealias InboundOut = SSHChannelData + typealias OutboundIn = SSHChannelData + typealias OutboundOut = SSHChannelData + + let command: String + let stdinData: Data? + let resultBox: VPhoneSSHCommandResultBox + let stdinChunkSize = 256 * 1024 + + var standardOutput = Data() + var standardError = Data() + var exitStatus: Int32 = 0 + var completed = false + var stdinOffset = 0 + + init(command: String, stdinData: Data?, resultBox: VPhoneSSHCommandResultBox) { + self.command = command + self.stdinData = stdinData + self.resultBox = resultBox + } + + func handlerAdded(context: ChannelHandlerContext) { + let loopBoundContext = NIOLoopBound(context, eventLoop: context.eventLoop) + context.channel.setOption(ChannelOptions.allowRemoteHalfClosure, value: true).whenFailure { error in + self.fail(error, context: loopBoundContext.value) + } + } + + func channelActive(context: ChannelHandlerContext) { + let loopBoundContext = NIOLoopBound(context, eventLoop: context.eventLoop) + if ProcessInfo.processInfo.environment["VPHONE_SSH_DEBUG"] == "1" { + FileHandle.standardError.write(Data("[ssh] exec handler active command=\(command)\n".utf8)) + } + let request = SSHChannelRequestEvent.ExecRequest(command: command, wantReply: true) + context.triggerUserOutboundEvent(request).whenComplete { result in + switch result { + case .success: + self.sendStandardInput(context: loopBoundContext.value) + case .failure(let error): + self.fail(error, context: loopBoundContext.value) + } + } + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let message = unwrapInboundIn(data) + guard case .byteBuffer(var bytes) = message.data, + let chunk = bytes.readData(length: bytes.readableBytes) + else { + return + } + + switch message.type { + case .channel: + standardOutput.append(chunk) + case .stdErr: + standardError.append(chunk) + default: + break + } + } + + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + if let exit = event as? SSHChannelRequestEvent.ExitStatus { + exitStatus = Int32(exit.exitStatus) + if ProcessInfo.processInfo.environment["VPHONE_SSH_DEBUG"] == "1" { + FileHandle.standardError.write(Data("[ssh] exit status=\(exit.exitStatus)\n".utf8)) + } + } else { + context.fireUserInboundEventTriggered(event) + } + } + + func channelInactive(context: ChannelHandlerContext) { + if ProcessInfo.processInfo.environment["VPHONE_SSH_DEBUG"] == "1" { + FileHandle.standardError.write(Data("[ssh] exec handler inactive command=\(command)\n".utf8)) + } + succeedIfNeeded() + context.fireChannelInactive() + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + fail(error, context: context) + } + + private func sendStandardInput(context: ChannelHandlerContext) { + guard let stdinData, !stdinData.isEmpty else { + context.close(mode: .output, promise: nil) + return + } + writeNextStandardInputChunk(context: context, stdinData: stdinData) + } + + private func writeNextStandardInputChunk(context: ChannelHandlerContext, stdinData: Data) { + if stdinOffset >= stdinData.count { + context.close(mode: .output, promise: nil) + return + } + + let nextOffset = min(stdinOffset + stdinChunkSize, stdinData.count) + let chunk = stdinData[stdinOffset.. + + private let promise: EventLoopPromise + private let lock = NSLock() + private var completed = false + + init(eventLoop: EventLoop) { + promise = eventLoop.makePromise(of: VPhoneSSHCommandResult.self) + futureResult = promise.futureResult + } + + func succeed(_ value: VPhoneSSHCommandResult) { + lock.lock() + defer { lock.unlock() } + guard !completed else { return } + completed = true + promise.succeed(value) + } + + func fail(_ error: Error) { + lock.lock() + defer { lock.unlock() } + guard !completed else { return } + completed = true + promise.fail(error) + } +} + +private final class VPhoneSSHConnectionState: @unchecked Sendable { + let readyPromise: EventLoopPromise + var sshHandler: NIOSSHHandler? + + init(eventLoop: EventLoop) { + readyPromise = eventLoop.makePromise(of: Void.self) + } +} + +private final class VPhoneSSHConnectionHandler: ChannelInboundHandler, @unchecked Sendable { + typealias InboundIn = Any + + let readyPromise: EventLoopPromise + var completed = false + + init(readyPromise: EventLoopPromise) { + self.readyPromise = readyPromise + } + + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + if event is UserAuthSuccessEvent { + if ProcessInfo.processInfo.environment["VPHONE_SSH_DEBUG"] == "1" { + FileHandle.standardError.write(Data("[ssh] auth success event\n".utf8)) + } + succeedIfNeeded() + } + context.fireUserInboundEventTriggered(event) + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + failIfNeeded(error) + context.fireErrorCaught(error) + } + + func channelInactive(context: ChannelHandlerContext) { + failIfNeeded(VPhoneSSHError.notConnected) + context.fireChannelInactive() + } + + private func succeedIfNeeded() { + guard !completed else { return } + completed = true + readyPromise.succeed(()) + } + + private func failIfNeeded(_ error: Error) { + guard !completed else { return } + completed = true + readyPromise.fail(error) + } +} + +private final class AcceptAllHostKeysDelegate: NIOSSHClientServerAuthenticationDelegate { + func validateHostKey(hostKey: NIOSSHPublicKey, validationCompletePromise: EventLoopPromise) { + validationCompletePromise.succeed(()) + } +} + +private final class VPhoneSSHErrorHandler: ChannelInboundHandler { + typealias InboundIn = Any + + func errorCaught(context: ChannelHandlerContext, error: Error) { + context.close(promise: nil) + } } diff --git a/sources/vphone-cli/VPhoneSSHTransport.swift b/sources/vphone-cli/VPhoneSSHTransport.swift new file mode 100644 index 0000000..85320b9 --- /dev/null +++ b/sources/vphone-cli/VPhoneSSHTransport.swift @@ -0,0 +1,350 @@ +import CommonCrypto +import Crypto +import Foundation +import NIOCore +import NIOSSH + +enum VPhoneSSHTransportError: Error, CustomStringConvertible { + case invalidKeySize(expected: Int, actual: Int) + case invalidIVSize(expected: Int, actual: Int) + case cryptorCreateFailed(CCCryptorStatus) + case cryptorUpdateFailed(CCCryptorStatus) + case invalidPacket + case invalidMAC + + var description: String { + switch self { + case let .invalidKeySize(expected, actual): + return "invalid SSH key size: expected \(expected), got \(actual)" + case let .invalidIVSize(expected, actual): + return "invalid SSH IV size: expected \(expected), got \(actual)" + case let .cryptorCreateFailed(status): + return "failed to create SSH AES-CTR cryptor: \(status)" + case let .cryptorUpdateFailed(status): + return "failed to update SSH AES-CTR cryptor: \(status)" + case .invalidPacket: + return "invalid SSH packet" + case .invalidMAC: + return "invalid SSH packet MAC" + } + } +} + +class AESCTRTransportProtectionBase: NIOSSHTransportProtection { + class var cipherName: String { fatalError("Override cipherName") } + class var macName: String? { fatalError("Override macName") } + class var keySizes: ExpectedKeySizes { fatalError("Override keySizes") } + class var macAlgorithm: CCHmacAlgorithm { fatalError("Override macAlgorithm") } + class var macLength: Int { fatalError("Override macLength") } + + static var cipherBlockSize: Int { 16 } + + var macBytes: Int { Self.macLength } + var lengthEncrypted: Bool { true } + + private var inboundCipher: AESCTRStreamCipher + private var outboundCipher: AESCTRStreamCipher + private var inboundMACKey: Data + private var outboundMACKey: Data + + required init(initialKeys: NIOSSHSessionKeys) throws { + let configured = try Self.makeState(from: initialKeys) + self.inboundCipher = configured.inboundCipher + self.outboundCipher = configured.outboundCipher + self.inboundMACKey = configured.inboundMACKey + self.outboundMACKey = configured.outboundMACKey + } + + func updateKeys(_ newKeys: NIOSSHSessionKeys) throws { + let configured = try Self.makeState(from: newKeys) + self.inboundCipher = configured.inboundCipher + self.outboundCipher = configured.outboundCipher + self.inboundMACKey = configured.inboundMACKey + self.outboundMACKey = configured.outboundMACKey + } + + func decryptFirstBlock(_ source: inout ByteBuffer) throws { + let start = source.readerIndex + guard let encrypted = source.getBytes(at: start, length: Self.cipherBlockSize) else { + throw VPhoneSSHTransportError.invalidPacket + } + let plaintext = try inboundCipher.update(encrypted) + source.setBytes(plaintext, at: start) + } + + func decryptAndVerifyRemainingPacket(_ source: inout ByteBuffer, sequenceNumber: UInt32) throws -> ByteBuffer { + let packetByteCount = source.readableBytes - Self.macLength + guard packetByteCount >= Self.cipherBlockSize else { + throw VPhoneSSHTransportError.invalidPacket + } + + let remainingCiphertextCount = packetByteCount - Self.cipherBlockSize + if remainingCiphertextCount > 0 { + let offset = source.readerIndex + Self.cipherBlockSize + guard let encrypted = source.getBytes(at: offset, length: remainingCiphertextCount) else { + throw VPhoneSSHTransportError.invalidPacket + } + let plaintext = try inboundCipher.update(encrypted) + source.setBytes(plaintext, at: offset) + } + + let packetStart = source.readerIndex + guard let plaintextPacket = source.getBytes(at: packetStart, length: packetByteCount), + let receivedMAC = source.getBytes(at: packetStart + packetByteCount, length: Self.macLength) + else { + throw VPhoneSSHTransportError.invalidPacket + } + + let expectedMAC = Self.computeMAC( + key: inboundMACKey, + sequenceNumber: sequenceNumber, + packet: plaintextPacket + ) + guard Self.constantTimeEqual(expectedMAC, receivedMAC) else { + throw VPhoneSSHTransportError.invalidMAC + } + + guard var packetBuffer = source.readSlice(length: packetByteCount) else { + throw VPhoneSSHTransportError.invalidPacket + } + source.moveReaderIndex(forwardBy: Self.macLength) + return try Self.payloadBuffer(fromPlaintextPacket: &packetBuffer) + } + + func encryptPacket(_ destination: inout ByteBuffer, sequenceNumber: UInt32) throws { + let packetStart = destination.readerIndex + let packetByteCount = destination.readableBytes + guard let plaintextPacket = destination.getBytes(at: packetStart, length: packetByteCount) else { + throw VPhoneSSHTransportError.invalidPacket + } + + let mac = Self.computeMAC( + key: outboundMACKey, + sequenceNumber: sequenceNumber, + packet: plaintextPacket + ) + let ciphertext = try outboundCipher.update(plaintextPacket) + destination.setBytes(ciphertext, at: packetStart) + destination.writeBytes(mac) + } + + private static func makeState( + from keys: NIOSSHSessionKeys + ) throws -> ( + inboundCipher: AESCTRStreamCipher, + outboundCipher: AESCTRStreamCipher, + inboundMACKey: Data, + outboundMACKey: Data + ) { + let inboundEncryptionKey = Data(keys.inboundEncryptionKey.withUnsafeBytes { Array($0) }) + let outboundEncryptionKey = Data(keys.outboundEncryptionKey.withUnsafeBytes { Array($0) }) + let inboundMACKey = Data(keys.inboundMACKey.withUnsafeBytes { Array($0) }) + let outboundMACKey = Data(keys.outboundMACKey.withUnsafeBytes { Array($0) }) + + guard inboundEncryptionKey.count == Self.keySizes.encryptionKeySize else { + throw VPhoneSSHTransportError.invalidKeySize( + expected: Self.keySizes.encryptionKeySize, + actual: inboundEncryptionKey.count + ) + } + guard outboundEncryptionKey.count == Self.keySizes.encryptionKeySize else { + throw VPhoneSSHTransportError.invalidKeySize( + expected: Self.keySizes.encryptionKeySize, + actual: outboundEncryptionKey.count + ) + } + guard inboundMACKey.count == Self.keySizes.macKeySize else { + throw VPhoneSSHTransportError.invalidKeySize( + expected: Self.keySizes.macKeySize, + actual: inboundMACKey.count + ) + } + guard outboundMACKey.count == Self.keySizes.macKeySize else { + throw VPhoneSSHTransportError.invalidKeySize( + expected: Self.keySizes.macKeySize, + actual: outboundMACKey.count + ) + } + guard keys.initialInboundIV.count == Self.keySizes.ivSize else { + throw VPhoneSSHTransportError.invalidIVSize( + expected: Self.keySizes.ivSize, + actual: keys.initialInboundIV.count + ) + } + guard keys.initialOutboundIV.count == Self.keySizes.ivSize else { + throw VPhoneSSHTransportError.invalidIVSize( + expected: Self.keySizes.ivSize, + actual: keys.initialOutboundIV.count + ) + } + + return ( + inboundCipher: try AESCTRStreamCipher(key: inboundEncryptionKey, iv: keys.initialInboundIV), + outboundCipher: try AESCTRStreamCipher(key: outboundEncryptionKey, iv: keys.initialOutboundIV), + inboundMACKey: inboundMACKey, + outboundMACKey: outboundMACKey + ) + } + + private static func computeMAC(key: Data, sequenceNumber: UInt32, packet: [UInt8]) -> [UInt8] { + var sequenceNumber = sequenceNumber.bigEndian + var mac = [UInt8](repeating: 0, count: Self.macLength) + + key.withUnsafeBytes { keyBuffer in + withUnsafeBytes(of: &sequenceNumber) { sequenceBuffer in + packet.withUnsafeBytes { packetBuffer in + CCHmacInitBuffer(Self.macAlgorithm, keyBuffer.baseAddress, keyBuffer.count) { context in + CCHmacUpdate(context, sequenceBuffer.baseAddress, sequenceBuffer.count) + CCHmacUpdate(context, packetBuffer.baseAddress, packetBuffer.count) + mac.withUnsafeMutableBytes { outputBuffer in + CCHmacFinal(context, outputBuffer.baseAddress) + } + } + } + } + } + + return mac + } + + private static func constantTimeEqual(_ lhs: [UInt8], _ rhs: [UInt8]) -> Bool { + guard lhs.count == rhs.count else { return false } + var diff: UInt8 = 0 + for index in lhs.indices { + diff |= lhs[index] ^ rhs[index] + } + return diff == 0 + } + + private static func payloadBuffer(fromPlaintextPacket packetBuffer: inout ByteBuffer) throws -> ByteBuffer { + packetBuffer.moveReaderIndex(forwardBy: MemoryLayout.size) + guard let paddingLength = packetBuffer.readInteger(as: UInt8.self) else { + throw VPhoneSSHTransportError.invalidPacket + } + let payloadLength = packetBuffer.readableBytes - Int(paddingLength) + guard payloadLength >= 0, + let payload = packetBuffer.readSlice(length: payloadLength), + packetBuffer.readerIndex + Int(paddingLength) == packetBuffer.writerIndex + else { + throw VPhoneSSHTransportError.invalidPacket + } + packetBuffer.moveReaderIndex(forwardBy: Int(paddingLength)) + return payload + } +} + +final class AES128CTRHMACSHA256TransportProtection: AESCTRTransportProtectionBase { + override class var cipherName: String { "aes128-ctr" } + override class var macName: String? { "hmac-sha2-256" } + override class var keySizes: ExpectedKeySizes { + .init(ivSize: 16, encryptionKeySize: 16, macKeySize: Int(CC_SHA256_DIGEST_LENGTH)) + } + override class var macAlgorithm: CCHmacAlgorithm { CCHmacAlgorithm(kCCHmacAlgSHA256) } + override class var macLength: Int { Int(CC_SHA256_DIGEST_LENGTH) } +} + +final class AES256CTRHMACSHA256TransportProtection: AESCTRTransportProtectionBase { + override class var cipherName: String { "aes256-ctr" } + override class var macName: String? { "hmac-sha2-256" } + override class var keySizes: ExpectedKeySizes { + .init(ivSize: 16, encryptionKeySize: 32, macKeySize: Int(CC_SHA256_DIGEST_LENGTH)) + } + override class var macAlgorithm: CCHmacAlgorithm { CCHmacAlgorithm(kCCHmacAlgSHA256) } + override class var macLength: Int { Int(CC_SHA256_DIGEST_LENGTH) } +} + +final class AES128CTRHMACSHA1TransportProtection: AESCTRTransportProtectionBase { + override class var cipherName: String { "aes128-ctr" } + override class var macName: String? { "hmac-sha1" } + override class var keySizes: ExpectedKeySizes { + .init(ivSize: 16, encryptionKeySize: 16, macKeySize: Int(CC_SHA1_DIGEST_LENGTH)) + } + override class var macAlgorithm: CCHmacAlgorithm { CCHmacAlgorithm(kCCHmacAlgSHA1) } + override class var macLength: Int { Int(CC_SHA1_DIGEST_LENGTH) } +} + +final class AES256CTRHMACSHA1TransportProtection: AESCTRTransportProtectionBase { + override class var cipherName: String { "aes256-ctr" } + override class var macName: String? { "hmac-sha1" } + override class var keySizes: ExpectedKeySizes { + .init(ivSize: 16, encryptionKeySize: 32, macKeySize: Int(CC_SHA1_DIGEST_LENGTH)) + } + override class var macAlgorithm: CCHmacAlgorithm { CCHmacAlgorithm(kCCHmacAlgSHA1) } + override class var macLength: Int { Int(CC_SHA1_DIGEST_LENGTH) } +} + +final class AESCTRStreamCipher { + private var cryptor: CCCryptorRef? + + init(key: Data, iv: [UInt8]) throws { + var cryptor: CCCryptorRef? + let status = key.withUnsafeBytes { keyBuffer in + iv.withUnsafeBytes { ivBuffer in + CCCryptorCreateWithMode( + CCOperation(kCCEncrypt), + CCMode(kCCModeCTR), + CCAlgorithm(kCCAlgorithmAES), + CCPadding(ccNoPadding), + ivBuffer.baseAddress, + keyBuffer.baseAddress, + key.count, + nil, + 0, + 0, + CCModeOptions(kCCModeOptionCTR_BE), + &cryptor + ) + } + } + guard status == kCCSuccess, let cryptor else { + throw VPhoneSSHTransportError.cryptorCreateFailed(status) + } + self.cryptor = cryptor + } + + deinit { + if let cryptor { + CCCryptorRelease(cryptor) + } + } + + func update(_ input: [UInt8]) throws -> [UInt8] { + guard let cryptor else { + throw VPhoneSSHTransportError.invalidPacket + } + if input.isEmpty { + return [] + } + + let outputCount = input.count + var output = [UInt8](repeating: 0, count: outputCount) + var bytesMoved = 0 + let status = input.withUnsafeBytes { inputBuffer in + output.withUnsafeMutableBytes { outputBuffer in + CCCryptorUpdate( + cryptor, + inputBuffer.baseAddress, + input.count, + outputBuffer.baseAddress, + outputCount, + &bytesMoved + ) + } + } + guard status == kCCSuccess, bytesMoved == input.count else { + throw VPhoneSSHTransportError.cryptorUpdateFailed(status) + } + return output + } +} + +private func CCHmacInitBuffer( + _ algorithm: CCHmacAlgorithm, + _ key: UnsafeRawPointer?, + _ keyLength: Int, + body: (UnsafeMutablePointer) -> Void +) { + var context = CCHmacContext() + CCHmacInit(&context, algorithm, key, keyLength) + body(&context) +} diff --git a/sources/vphone-cli/VPhoneSetupMachineCLI.swift b/sources/vphone-cli/VPhoneSetupMachineCLI.swift index 9d7e2da..655996d 100644 --- a/sources/vphone-cli/VPhoneSetupMachineCLI.swift +++ b/sources/vphone-cli/VPhoneSetupMachineCLI.swift @@ -56,16 +56,13 @@ struct SetupMachineCLI: AsyncParsableCommand { }() @Flag(help: "Skip setup_tools/build stage") - var skipProjectSetup: Bool = { - ["1", "true", "yes"].contains(ProcessInfo.processInfo.environment["SKIP_PROJECT_SETUP"]?.lowercased() ?? "") - }() + var skipProjectSetup = false @Flag(help: "Auto-continue first boot and final boot analysis") - var nonInteractive: Bool = { - ["1", "true", "yes"].contains(ProcessInfo.processInfo.environment["NONE_INTERACTIVE"]?.lowercased() ?? "") - }() + var nonInteractive = false mutating func run() async throws { + let environment = ProcessInfo.processInfo.environment let runner = try SetupMachineRunner( projectRoot: projectRoot.standardizedFileURL, vmDirectoryName: vmDirectory, @@ -79,11 +76,15 @@ struct SetupMachineCLI: AsyncParsableCommand { cloudOSSource: cloudOSSource, ipswDirectory: ipswDirectory?.standardizedFileURL, variant: variant, - skipProjectSetup: skipProjectSetup, - nonInteractive: nonInteractive + skipProjectSetup: skipProjectSetup || Self.boolEnvironmentValue("SKIP_PROJECT_SETUP", environment: environment), + nonInteractive: nonInteractive || Self.boolEnvironmentValue("NONE_INTERACTIVE", environment: environment) ) try await runner.run() } + + static func boolEnvironmentValue(_ key: String, environment: [String: String]) -> Bool { + ["1", "true", "yes"].contains(environment[key]?.lowercased() ?? "") + } } private struct SetupMachineRunner {