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:
@@ -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 直连(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 IP;relay=loopback↔tsnet UDP)
|
- **mosh over tsnet**:额外加 `-txTsnetKey <authkey>`(-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`)
|
- 自定义 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 <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 过期,深挂起需真机锁屏补测。**
|
**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 过期,深挂起需真机锁屏补测。**
|
||||||
|
|
||||||
|
|||||||
@@ -240,10 +240,14 @@ final class SSHTerminalModel: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - 公开入口
|
// MARK: - 公开入口
|
||||||
|
|
||||||
|
/// M4b:非 nil 时用 publickey(SE/软件签名器) 认证而非密码。
|
||||||
|
private var pubkeySigner: SigningKeyProvider?
|
||||||
|
|
||||||
func connect(host: String, port: Int, username: String, password: String) {
|
func connect(host: String, port: Int, username: String, password: String) {
|
||||||
let egress = tsnetAuthKey != nil ? "tsnet" : "direct"
|
let egress = tsnetAuthKey != nil ? "tsnet" : "direct"
|
||||||
|
let auth: SSHConfig.Authentication = pubkeySigner.map { .publicKeyCallback($0) } ?? .password(password)
|
||||||
config = SSHConfig(host: host, port: port, username: username,
|
config = SSHConfig(host: host, port: port, username: username,
|
||||||
authentication: .password(password),
|
authentication: auth,
|
||||||
hostKeyVerifier: makeHostKeyVerifier(egress: egress, host: host, port: port))
|
hostKeyVerifier: makeHostKeyVerifier(egress: egress, host: host, port: port))
|
||||||
run(machine.reduce(.connectRequested))
|
run(machine.reduce(.connectRequested))
|
||||||
}
|
}
|
||||||
@@ -324,6 +328,13 @@ final class SSHTerminalModel: ObservableObject {
|
|||||||
autoCommand = nil // mosh 模式用自己的引导流程,不用 txAutoCommand
|
autoCommand = nil // mosh 模式用自己的引导流程,不用 txAutoCommand
|
||||||
}
|
}
|
||||||
let realPort = port == 0 ? 22 : port
|
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 路径。
|
// 无头验证:播种一个假 pin → 真实 host key 将 mismatch,触发 alert 路径。
|
||||||
if let b64 = d.string(forKey: "txHostKeyPinOverrideBase64"), let fake = Data(base64Encoded: b64) {
|
if let b64 = d.string(forKey: "txHostKeyPinOverrideBase64"), let fake = Data(base64Encoded: b64) {
|
||||||
let egress = tsnetAuthKey != nil ? "tsnet" : "direct"
|
let egress = tsnetAuthKey != nil ? "tsnet" : "direct"
|
||||||
|
|||||||
98
apps/TerminalX/iOS/SigningKeyProvider.swift
Normal file
98
apps/TerminalX/iOS/SigningKeyProvider.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
| **R5** Go+gvisor+C+++Swift 同进程 | ✅ retire | 全程真机无崩溃 |
|
| **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 证伪) |
|
| **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 |
|
| **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** 合规提审 | ⏸️ 暂缓 | 用户指示暂缓 |
|
| **M5** 合规提审 | ⏸️ 暂缓 | 用户指示暂缓 |
|
||||||
|
|
||||||
## 5. 代码地图与数据流
|
## 5. 代码地图与数据流
|
||||||
@@ -55,6 +55,8 @@
|
|||||||
- `CSSH/`(C target:libssh2 头 + modulemap,供 `import CSSH`)、`CSSHCore`(binaryTarget,library-only)。
|
- `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 自动解析)。
|
- `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 <port> <key>`(注意 Swift 视 `\r\n` 为单 Character,行尾判定须用 `isNewline`)。
|
- `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`。
|
- **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/`
|
- `apps/TerminalX/iOS/`
|
||||||
- `SSHTerminalModel.swift` —— 核心编排:`OutputGate`(surface-ready 缓冲)、`TransportHolder`、`TsnetManager`(tsnet 节点 up/dial+重试)、`TsnetError`、`TmuxRouter`(raw/gateway 字节分流)、状态机驱动 startSession(直连/tsnet 分支)、`autoConnectIfConfigured`(启动参数)。
|
- `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:)`。
|
- **数据流(tsnet)**:`TsnetManager.ensureUp`(auth key)→`dialFD`(socketpair fd)→`SSHSession(preconnectedFD:)`。
|
||||||
|
|
||||||
## 6. 已知问题 / TODO(继续工作的入口)
|
## 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 降级路径未做。
|
- **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 状态)。
|
- **tsnet 首连**:已加 dial 重试;更优是等 peer online 再 dial(读 PeersJSON online 状态)。
|
||||||
- **签名/真机**:`CODE_SIGNING_ALLOWED=NO`,仅模拟器;上真机/TestFlight 需配置签名 + entitlements(tsnet 用户态**无需** NE entitlement,利好审核)。
|
- **签名/真机**:`CODE_SIGNING_ALLOWED=NO`,仅模拟器;上真机/TestFlight 需配置签名 + entitlements(tsnet 用户态**无需** NE entitlement,利好审核)。
|
||||||
|
|||||||
@@ -3,6 +3,34 @@ import Darwin
|
|||||||
import CSSH
|
import CSSH
|
||||||
import TXCore
|
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),密码 / 私钥认证,
|
/// 基于 libssh2 的 SSH 传输。M0:系统 socket 直连(不过 tsnet),密码 / 私钥认证,
|
||||||
/// 打开 PTY + shell,非阻塞事件循环把 PTY 输出投递给 `onBytes`。
|
/// 打开 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)) }
|
guard rc == 0 else { throw SSHError.authentication(Int(rc)) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ public struct SSHConfig: Sendable {
|
|||||||
public enum Authentication: Sendable {
|
public enum Authentication: Sendable {
|
||||||
case password(String)
|
case password(String)
|
||||||
case privateKey(pem: String, passphrase: String?)
|
case privateKey(pem: String, passphrase: String?)
|
||||||
|
/// publickey 认证 + 外部签名器(Secure Enclave / 软件密钥)。私钥不出安全域,仅经回调签名。
|
||||||
|
case publicKeyCallback(SSHSigner)
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(
|
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 {
|
public enum SSHError: Error, Sendable, Equatable {
|
||||||
case socket(String)
|
case socket(String)
|
||||||
case handshake(Int)
|
case handshake(Int)
|
||||||
|
|||||||
Reference in New Issue
Block a user