diff --git a/CLAUDE.md b/CLAUDE.md index 0afd0e4..e4a84da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,8 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具 - **mosh 直连(LAN)**:`-txHost H -txPort 22 -txUser U -txPass PW -txMosh 1`(登录后跑 mosh-server→解析 MOSH CONNECT→mosh_main 直连 UDP) - **mosh over tsnet**:额外加 `-txTsnetKey `(-txHost 填 tailnet IP;relay=loopback↔tsnet UDP) - 自定义 mosh-server 命令:`-txMoshServerCmd "…"`(默认 `mosh-server new -s -c 256 -l LANG=en_US.UTF-8 -l LC_ALL=en_US.UTF-8`) +- **M4 publickey 认证**:`-txPubkeyAuth 1`(不给 -txPass;SigningKeyProvider 造 P-256 key,日志打 authorized_keys 行,加到服务器后重连免密登录) +- **M4 host key mismatch 测试**:`-txHostKeyPinOverrideBase64 `(播种假 pin → 真 host key 不符 → 弹告警) **M3 挂起→恢复无头验证**:退后台 `simctl openurl $UDID https://apple.com`;冻结 `kill -STOP $(pgrep -x TerminalX)`;解冻 `kill -CONT`;前台 `simctl openurl $UDID "terminalx://resume"`(URL scheme 已注册)。日志 `log stream | grep -E 'MOSHDBG|TXM3'` 看 `wake pulse`→`TXM3 recovered` 时间差判 <3s。**坑:模拟器看门狗会杀掉被 STOP 冻结过久(>~30s)的 app;且模拟器不复现 socket defunct/WG 过期,深挂起需真机锁屏补测。** diff --git a/apps/TerminalX/iOS/SSHTerminalModel.swift b/apps/TerminalX/iOS/SSHTerminalModel.swift index 0ff9790..0b55731 100644 --- a/apps/TerminalX/iOS/SSHTerminalModel.swift +++ b/apps/TerminalX/iOS/SSHTerminalModel.swift @@ -240,10 +240,14 @@ final class SSHTerminalModel: ObservableObject { // MARK: - 公开入口 + /// M4b:非 nil 时用 publickey(SE/软件签名器) 认证而非密码。 + private var pubkeySigner: SigningKeyProvider? + func connect(host: String, port: Int, username: String, password: String) { let egress = tsnetAuthKey != nil ? "tsnet" : "direct" + let auth: SSHConfig.Authentication = pubkeySigner.map { .publicKeyCallback($0) } ?? .password(password) config = SSHConfig(host: host, port: port, username: username, - authentication: .password(password), + authentication: auth, hostKeyVerifier: makeHostKeyVerifier(egress: egress, host: host, port: port)) run(machine.reduce(.connectRequested)) } @@ -324,6 +328,13 @@ final class SSHTerminalModel: ObservableObject { autoCommand = nil // mosh 模式用自己的引导流程,不用 txAutoCommand } let realPort = port == 0 ? 22 : port + // M4b:publickey 认证(SE/软件签名器)。日志打出 authorized_keys 行供加入服务器。 + if d.bool(forKey: "txPubkeyAuth") { + pubkeySigner = SigningKeyProvider.loadOrCreate() + if let s = pubkeySigner { + NSLog("M4DBG authorized_keys hw=\(s.isHardwareBacked): \(s.authorizedKeysLine)") + } + } // 无头验证:播种一个假 pin → 真实 host key 将 mismatch,触发 alert 路径。 if let b64 = d.string(forKey: "txHostKeyPinOverrideBase64"), let fake = Data(base64Encoded: b64) { let egress = tsnetAuthKey != nil ? "tsnet" : "direct" diff --git a/apps/TerminalX/iOS/SigningKeyProvider.swift b/apps/TerminalX/iOS/SigningKeyProvider.swift new file mode 100644 index 0000000..e867885 --- /dev/null +++ b/apps/TerminalX/iOS/SigningKeyProvider.swift @@ -0,0 +1,98 @@ +import Foundation +import Security +import TXCore +import TXTransport + +/// SSH publickey 认证的签名器。私钥优先放 Secure Enclave(不可导出); +/// 模拟器/无 SE 环境回退软件 P-256(可导出,落沙盒文件持久化)——**同一份 SSHSigner 代码路径**, +/// 差异仅生成参数一行,把真机验证压缩到"SE 生成/签名成功"两行。 +final class SigningKeyProvider: SSHSigner, @unchecked Sendable { + private let privateKey: SecKey + let publicKeyBlob: [UInt8] + let isHardwareBacked: Bool + + /// authorized_keys 行(复制到服务器即可用本 key 登录)。 + var authorizedKeysLine: String { + "ecdsa-sha2-nistp256 " + Data(publicKeyBlob).base64EncodedString() + " terminalx@device" + } + + private init(privateKey: SecKey, hardware: Bool) { + self.privateKey = privateKey + self.isHardwareBacked = hardware + let pub = SecKeyCopyPublicKey(privateKey)! + let x963 = SecKeyCopyExternalRepresentation(pub, nil)! as Data // 0x04||X||Y (65B) + self.publicKeyBlob = ECDSAConv.p256PublicKeyBlob(x963: [UInt8](x963)) + } + + // MARK: - SSHSigner + + func signSSH(_ data: [UInt8]) -> [UInt8]? { + var err: Unmanaged? + guard let der = SecKeyCreateSignature( + privateKey, .ecdsaSignatureMessageX962SHA256, Data(data) as CFData, &err) as Data? else { + NSLog("M4DBG SecKeyCreateSignature failed: \(String(describing: err?.takeRetainedValue()))") + return nil + } + return ECDSAConv.derToSSHSignature([UInt8](der)) // DER SEQUENCE{r,s} → SSH mpint r||mpint s + } + + // MARK: - 生成 / 加载 + + private static var keyFileURL: URL { + let base = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0] + return URL(fileURLWithPath: base).appendingPathComponent("ssh-ecdsa-p256.key") + } + + static func loadOrCreate() -> SigningKeyProvider? { + // 1) 软件密钥文件(模拟器持久化,跨启动稳定)。 + if let data = try? Data(contentsOf: keyFileURL), + let key = SecKeyCreateWithData(data as CFData, [ + kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeyClass: kSecAttrKeyClassPrivate, + kSecAttrKeySizeInBits: 256, + ] as CFDictionary, nil) { + return SigningKeyProvider(privateKey: key, hardware: false) + } + // 2) Secure Enclave(真机;不可导出)。 + if let se = createSecureEnclaveKey() { + return SigningKeyProvider(privateKey: se, hardware: true) + } + // 3) 回退软件 P-256(模拟器/无 SE),导出落文件。 + guard let sw = createSoftwareKey() else { return nil } + if let ext = SecKeyCopyExternalRepresentation(sw, nil) as Data? { + try? ext.write(to: keyFileURL, options: .atomic) + } + return SigningKeyProvider(privateKey: sw, hardware: false) + } + + private static func createSecureEnclaveKey() -> SecKey? { + guard let access = SecAccessControlCreateWithFlags( + nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .privateKeyUsage, nil) else { return nil } + let attrs: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeySizeInBits as String: 256, + kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, + kSecPrivateKeyAttrs as String: [ + kSecAttrIsPermanent as String: true, // 持久化到 keychain(真机签名构建);未签名/模拟器会失败(-34018)→回退软件+文件 + kSecAttrApplicationTag as String: "ai.athom.terminalx.sshkey".data(using: .utf8)!, + kSecAttrAccessControl as String: access, + ], + ] + var err: Unmanaged? + let k = SecKeyCreateRandomKey(attrs as CFDictionary, &err) + if k == nil { NSLog("M4DBG SE key unavailable (预期于模拟器): \(String(describing: err?.takeRetainedValue()))") } + return k + } + + private static func createSoftwareKey() -> SecKey? { + let attrs: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeySizeInBits as String: 256, + kSecPrivateKeyAttrs as String: [kSecAttrIsPermanent as String: false], + ] + var err: Unmanaged? + let k = SecKeyCreateRandomKey(attrs as CFDictionary, &err) + if k == nil { NSLog("M4DBG software key create failed: \(String(describing: err?.takeRetainedValue()))") } + return k + } +} diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 361cf21..7892066 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -42,7 +42,7 @@ | **R5** Go+gvisor+C+++Swift 同进程 | ✅ retire | 全程真机无崩溃 | | **M2** mosh | ✅ 完成(LAN + tsnet 均验证) | libmoshios(C++)+protobuf-lite→`MoshCore.xcframework`;UDP relay over tsnet;**LAN 直连 + mosh-over-tsnet 均端到端验证通过**(R1 证伪) | | **M3** 生命周期深化 | ✅ mosh 快恢复(真机深挂起待补) | scenePhase FSM + **mosh 挂起→恢复<3s 已验证**(模拟器:15/30s 冻结→前台,wake pulse→1.3s 收到服务器包,"已恢复");冷启动重连(SSP 序号+key 持久化)划入 M4/M5 | -| **M4** 安全 | ◐ host key 校验完成 / SE 认证待做 | **host key TOFU pin 已做并验证**(首次信任+不符告警,指纹对齐 ssh-keygen,MITM 敞口关闭;pin 存沙盒文件因未签名 app 无 keychain 权限);Secure Enclave publickey 认证(M4b) 待做 | +| **M4** 安全 | ✅ 完成(真机 SE 硬件待补) | **host key TOFU pin**(首次信任+不符告警,指纹对齐 ssh-keygen,MITM 关闭)+ **publickey 认证**(P-256 sign_callback→SecKeyCreateSignature→DER→SSH mpint,免密登录 192.168.9.199 验证通过;SE 优先/未签名回退软件+文件,SE 生成在模拟器 hw=true 可达,真机 SE 硬件签名+keychain 持久化待补) | | **M5** 合规提审 | ⏸️ 暂缓 | 用户指示暂缓 | ## 5. 代码地图与数据流 @@ -55,6 +55,8 @@ - `CSSH/`(C target:libssh2 头 + modulemap,供 `import CSSH`)、`CSSHCore`(binaryTarget,library-only)。 - `CMosh/`(C target:`moshiosbridge.h`(mosh_main)+modulemap+占位 shim.c,供 `import CMosh`)、`MoshCore`(binaryTarget,library-only);TXTransport 链 `libc++`/`libz`(mosh 是 C++ 且 compressor 用 zlib;CommonCrypto 属 libSystem 自动解析)。 - `TXCore/Mosh/MoshConnect.swift`:`MoshConnectScanner` 流式扫描 `MOSH CONNECT `(注意 Swift 视 `\r\n` 为单 Character,行尾判定须用 `isNewline`)。 + - `TXCore/SSH/`(M4):`SSHWire`(string/mpint 编码)/`ECDSAConv`(DER→SSH 签名·P256 blob)/`HostKey`(opensshFingerprint·evaluate·HostTriple·KnownHostsStore 协议),SSHWireTests 10 单测(真实指纹向量+高位 DER)。 +- **M4 安全**:`TXTransport` `SSHConfig.hostKeyVerifier`(闭包)+`SSHError.hostKeyMismatch`+`SSHSigner` 协议+`Authentication.publicKeyCallback`;`SSHSession` 握手后 `libssh2_session_hostkey` 校验、`sshSignCallback`(@convention(c)+malloc 出参)接 `libssh2_userauth_publickey`。app:`KeychainKnownHostsStore`(文件后端 TOFU pin)、`SigningKeyProvider`(SE 优先/软件回退+文件持久化,`SecKeyCreateSignature`)、mismatch alert(ContentView)。 - **M3 快恢复链**:`bridge.go` 加 `Node.WakeUp()`(tsnet `InjectEvent`+`MagicSock.Rebind/ReSTUN`) 与 `MoshRelay.Rebind()`(同端口重开 loopback、泵按 socket 生成容错);`iosclient.cc` 加 `g_mosh_last_heard`(收到服务器包即更新,导出 `mosh_last_heard_ms()`) 并恢复 SIGCONT 全屏重绘;`MoshSession.nudge()`(SIGCONT) + `lastHeardMs()`;`SSHTerminalModel` 前台执行"唤醒脉冲三连"(WakeUp+Rebind+nudge)+healthy 轮询(探针增长→`moshHealthy`)+恢复看门狗+`beginBackgroundTask`。 - `apps/TerminalX/iOS/` - `SSHTerminalModel.swift` —— 核心编排:`OutputGate`(surface-ready 缓冲)、`TransportHolder`、`TsnetManager`(tsnet 节点 up/dial+重试)、`TsnetError`、`TmuxRouter`(raw/gateway 字节分流)、状态机驱动 startSession(直连/tsnet 分支)、`autoConnectIfConfigured`(启动参数)。 @@ -66,7 +68,7 @@ - **数据流(tsnet)**:`TsnetManager.ensureUp`(auth key)→`dialFD`(socketpair fd)→`SSHSession(preconnectedFD:)`。 ## 6. 已知问题 / TODO(继续工作的入口) -- **M4 安全**:`SSHSession.connect()` 有 `TODO(M4)`:不校验 host key(接受任意)。需接 known_hosts 三元组 pin(egress,host,port) + Secure Enclave 私钥(`libssh2_userauth_publickey_frommemory`+`SecKeyCreateSignature`)。 +- **M4 安全(已完成,剩真机 SE)**:host key TOFU pin(`SSHConfig.hostKeyVerifier`→app `KeychainKnownHostsStore` 文件后端) + publickey 认证(`Authentication.publicKeyCallback(SSHSigner)`→`SSHSession` C `sshSignCallback`→`SigningKeyProvider`)。**剩**:真机验证 SE 硬件密钥生成+签名+keychain 持久化(未签名/模拟器回退软件+文件);`libssh2_session_method_pref` 锁定 pinned hostkey 类型防算法漂移误报(fable 建议,未做);M4DBG NSLog 埋点可清理。 - **tmux 多 pane 分屏 + 动态 resize**:✅ 已做(pane-per-surface + layout rect 绝对定位渲染 + 按 pane %output 路由 + tap 切焦点/select-pane + attach `refresh-client -C` 设尺寸 + **iPadOS 拖拽窗口/旋转 → `onChange(geo.size)`→`containerResized`(点×displayScale÷cell像素)→debounce→refresh-client -C→%layout-change→更新 frame**,验证:容器 1032x1280@2x cell16x35→129x73,tmux 精确采纳)。**剩余**:完整 scrollback(仍靠 capture-pane 抓当前屏);pause 流控(`%pause/%continue`,防单 pane 刷屏);split/kill pane 手势;tmux<3.1 降级路径未做。 - **tsnet 首连**:已加 dial 重试;更优是等 peer online 再 dial(读 PeersJSON online 状态)。 - **签名/真机**:`CODE_SIGNING_ALLOWED=NO`,仅模拟器;上真机/TestFlight 需配置签名 + entitlements(tsnet 用户态**无需** NE entitlement,利好审核)。 diff --git a/packages/TXTransport/Sources/TXTransport/SSHSession.swift b/packages/TXTransport/Sources/TXTransport/SSHSession.swift index 2418fd5..0f4b9c5 100644 --- a/packages/TXTransport/Sources/TXTransport/SSHSession.swift +++ b/packages/TXTransport/Sources/TXTransport/SSHSession.swift @@ -3,6 +3,34 @@ import Darwin import CSSH import TXCore +/// 承载 SSHSigner 供 libssh2 C 回调经 abstract 指针取用。 +final class SignerBox { + let signer: SSHSigner + init(_ s: SSHSigner) { self.signer = s } +} + +/// libssh2 publickey sign 回调(LIBSSH2_USERAUTH_PUBLICKEY_SIGN_FUNC):从 abstract 取签名器, +/// 对 data 签名,把 SSH 格式签名体经 malloc 出参交回(libssh2 用 free 释放)。返回 0 成功。 +private func sshSignCallback( + _ session: OpaquePointer?, + _ sig: UnsafeMutablePointer?>?, + _ sigLen: UnsafeMutablePointer?, + _ data: UnsafePointer?, + _ dataLen: Int, + _ abstract: UnsafeMutablePointer? +) -> Int32 { + guard let boxPtr = abstract?.pointee else { return -1 } + let box = Unmanaged.fromOpaque(boxPtr).takeUnretainedValue() + let input = (data != nil && dataLen > 0) ? Array(UnsafeBufferPointer(start: data, count: dataLen)) : [] + guard let sshSig = box.signer.signSSH(input), let out = sig else { return -1 } + let n = sshSig.count + guard let buf = malloc(n)?.assumingMemoryBound(to: UInt8.self) else { return -1 } + sshSig.withUnsafeBytes { _ = memcpy(buf, $0.baseAddress, n) } + out.pointee = buf + sigLen?.pointee = n + return 0 +} + /// 基于 libssh2 的 SSH 传输。M0:系统 socket 直连(不过 tsnet),密码 / 私钥认证, /// 打开 PTY + shell,非阻塞事件循环把 PTY 输出投递给 `onBytes`。 /// @@ -171,6 +199,19 @@ public final class SSHSession: Transport, @unchecked Sendable { } } } + case .publicKeyCallback(let signer): + // 私钥不出安全域:libssh2 经 sign_callback 拿签名(SE/软件密钥在 app 层)。 + let box = SignerBox(signer) + let boxPtr = Unmanaged.passRetained(box).toOpaque() + defer { Unmanaged.fromOpaque(boxPtr).release() } + var abstract: UnsafeMutableRawPointer? = boxPtr + let blob = signer.publicKeyBlob + rc = user.withCString { u in + blob.withUnsafeBufferPointer { bp in + libssh2_userauth_publickey(session, u, bp.baseAddress, bp.count, + sshSignCallback, &abstract) + } + } } guard rc == 0 else { throw SSHError.authentication(Int(rc)) } } diff --git a/packages/TXTransport/Sources/TXTransport/Transport.swift b/packages/TXTransport/Sources/TXTransport/Transport.swift index eee4b4d..354a4f8 100644 --- a/packages/TXTransport/Sources/TXTransport/Transport.swift +++ b/packages/TXTransport/Sources/TXTransport/Transport.swift @@ -39,6 +39,8 @@ public struct SSHConfig: Sendable { public enum Authentication: Sendable { case password(String) case privateKey(pem: String, passphrase: String?) + /// publickey 认证 + 外部签名器(Secure Enclave / 软件密钥)。私钥不出安全域,仅经回调签名。 + case publicKeyCallback(SSHSigner) } public init( @@ -62,6 +64,15 @@ public struct SSHConfig: Sendable { } } +/// SSH publickey 签名器:暴露 SSH 格式公钥 blob,并对挑战数据产出 SSH 格式签名。 +/// 实现方(app 层)用 Secure Enclave / 软件 P-256 密钥;私钥永不出安全域,只经 signSSH 使用。 +public protocol SSHSigner: AnyObject, Sendable { + /// SSH 线格式公钥 blob(如 `string("ecdsa-sha2-nistp256")||string("nistp256")||string(Q)`)。 + var publicKeyBlob: [UInt8] { get } + /// 对 libssh2 给的挑战数据签名,返回 SSH 线格式签名体(ecdsa 为 `mpint r||mpint s`,不含外层 string)。失败返回 nil。 + func signSSH(_ data: [UInt8]) -> [UInt8]? +} + public enum SSHError: Error, Sendable, Equatable { case socket(String) case handshake(Int)