feat: M4b publickey 认证(SE 优先/软件回退,sign_callback)

- TXTransport: SSHSigner 协议 + Authentication.publicKeyCallback;SSHSession 用
  @convention(c) sshSignCallback(abstract 桥 SignerBox + malloc 出参)接 libssh2_userauth_publickey
- app SigningKeyProvider: SE 优先(kSecAttrTokenIDSecureEnclave),未签名/无 SE 回退软件 P-256
  (导出落文件持久化),同一 SSHSigner 路径;SecKeyCreateSignature(ecdsa)→ECDSAConv.derToSSHSignature
- 公钥 blob=ecdsa-sha2-nistp256||nistp256||X963;-txPubkeyAuth 日志打 authorized_keys 行
- 验证(192.168.9.199):不给密码,公钥加 authorized_keys 后免密登录成功(whoami=kid);
  模拟器 SE 生成 hw=true 可达,真机 SE 硬件签名+keychain 持久化待补

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kid
2026-07-24 18:17:30 +08:00
parent 1a257fae7f
commit 5dbe937222
6 changed files with 168 additions and 3 deletions

View File

@@ -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 <authkey>`-txHost 填 tailnet IPrelay=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`(不给 -txPassSigningKeyProvider 造 P-256 key日志打 authorized_keys 行,加到服务器后重连免密登录)
- **M4 host key mismatch 测试**`-txHostKeyPinOverrideBase64 <b64>`(播种假 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 过期,深挂起需真机锁屏补测。**

View File

@@ -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
// M4bpublickey 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"

View File

@@ -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<CFError>?
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<CFError>?
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<CFError>?
let k = SecKeyCreateRandomKey(attrs as CFDictionary, &err)
if k == nil { NSLog("M4DBG software key create failed: \(String(describing: err?.takeRetainedValue()))") }
return k
}
}

View File

@@ -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-keygenMITM 敞口关闭pin 存沙盒文件因未签名 app 无 keychain 权限Secure Enclave publickey 认证(M4b) 待做 |
| **M4** 安全 | ✅ 完成(真机 SE 硬件待补) | **host key TOFU pin**(首次信任+不符告警,指纹对齐 ssh-keygenMITM 关闭)+ **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 targetlibssh2 头 + modulemap`import CSSH`)、`CSSHCore`binaryTargetlibrary-only
- `CMosh/`C target`moshiosbridge.h`(mosh_main)+modulemap+占位 shim.c`import CMosh`)、`MoshCore`binaryTargetlibrary-onlyTXTransport 链 `libc++`/`libz`mosh 是 C++ 且 compressor 用 zlibCommonCrypto 属 libSystem 自动解析)。
- `TXCore/Mosh/MoshConnect.swift``MoshConnectScanner` 流式扫描 `MOSH CONNECT <port> <key>`(注意 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→129x73tmux 精确采纳)。**剩余**:完整 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 需配置签名 + entitlementstsnet 用户态**无需** NE entitlement利好审核)。

View File

@@ -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<UnsafeMutablePointer<UInt8>?>?,
_ sigLen: UnsafeMutablePointer<Int>?,
_ data: UnsafePointer<UInt8>?,
_ dataLen: Int,
_ abstract: UnsafeMutablePointer<UnsafeMutableRawPointer?>?
) -> Int32 {
guard let boxPtr = abstract?.pointee else { return -1 }
let box = Unmanaged<SignerBox>.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<SignerBox>.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)) }
}

View File

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