request_user_secret use zeroize

This commit is contained in:
Kaede Akino committed 2026-09-21 10:42:05 +08:00
1 parent a31f93a40c
commit 9cfe2005d6
30 files changed
+348 -314

No files matched your search

+14 -8
View File
@@ -720,7 +720,7 @@ struct AgentUserInputTests {
}
@Test
func aSecretAnswersThroughItsOwnDraftAndNotTheSharedOne() async {
func aSecretAnswersThroughItsOwnDraftAndNotTheSharedOne() async throws {
let conversation = ConversationModel()
let pending = AgentUserInputRequest(
questions: [AgentUserQuestion(id: "secret", prompt: "Sudo password?", kind: .secret)]
@@ -736,12 +736,13 @@ struct AgentUserInputTests {
#expect(conversation.canSubmitUserInput)
conversation.submitUserInput()
#expect(
await answering.value
== .answered([
AgentUserInputAnswer(questionID: "secret", value: .secret("hunter2"))
])
)
guard case .answered(let answers) = await answering.value,
case .secret(let secret)? = answers.first?.value
else {
Issue.record("Expected one secret answer")
return
}
#expect(try secret.withUnsafeBytes { Array($0) } == Array("hunter2".utf8))
// Never left behind in the plain drafts, which are what the transcript
// and the next question read from.
#expect(conversation.userInputDraft(for: "secret").text.isEmpty)
@@ -805,7 +806,7 @@ struct AgentUserInputTests {
func aSecretIsNeverReportedBackToTheModel() {
let rows = RequestUserInputTool.reported(
[AgentUserQuestion(id: "secret", prompt: "Password?", kind: .secret)],
[AgentUserInputAnswer(questionID: "secret", value: .secret("hunter2"))]
[AgentUserInputAnswer(questionID: "secret", value: .secret(agentSecret("hunter2")))]
)
#expect(field(rows[0], "answer") == .null)
#expect(field(rows[0], "custom_text") == .null)
@@ -817,6 +818,11 @@ struct AgentUserInputTests {
row.objectValue?[key]
}
private func agentSecret(_ value: String) -> AgentSecret {
var bytes = Data(value.utf8)
return AgentSecret(consumingUTF8: &bytes)
}
private func argumentsFor(_ toolName: String, timeout: Int) -> AgentJSONValue {
if toolName == "request_user_secret" {
return .object([
@@ -204,7 +204,7 @@ private actor MetricsLogSession: SSHConnectionProtocol {
init(result: SSHExecResult) { self.result = result }
func connect(using _: SSHConnectionConfiguration) async throws {}
func execute(
_: String, workingDirectory _: String?, stdin _: Data?, timeout _: TimeInterval,
_: String, workingDirectory _: String?, stdin _: SSHCommandInput?, timeout _: TimeInterval,
outputLimit _: Int, onOutput _: SSHExecOutputHandler?
) async throws -> SSHExecResult { result }
func openSFTPChannel() async throws -> any SFTPChannelProtocol { throw SFTPError.notConnected }
+1 -1
View File
@@ -135,7 +135,7 @@ private actor FramedMetricsSession: SSHConnectionProtocol {
func connect(using _: SSHConnectionConfiguration) async throws {}
func execute(
_ command: String, workingDirectory _: String?, stdin _: Data?, timeout _: TimeInterval,
_ command: String, workingDirectory _: String?, stdin _: SSHCommandInput?, timeout _: TimeInterval,
outputLimit _: Int, onOutput _: SSHExecOutputHandler?
) async throws -> SSHExecResult {
executedCommands.append(command)
+162 -75
View File
@@ -6067,6 +6067,23 @@
}
}
},
"Connecting to proxy" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "正在连接代理"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "正在連線代理"
}
}
}
},
"Connecting…" : {
"extractionState" : "stale",
"localizations" : {
@@ -8123,15 +8140,9 @@
}
}
},
"Destination Host (Port Forwarding)" : {
"Destination host" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Destination Host"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@@ -8146,26 +8157,15 @@
}
}
},
"Destination Port" : {
"Destination Host (Port Forwarding)" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "目标端口"
"value" : "Destination Host"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "目標連接埠"
}
}
}
},
"Destination host" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@@ -8197,6 +8197,23 @@
}
}
},
"Destination Port" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "目标端口"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "目標連接埠"
}
}
}
},
"Destroy" : {
"extractionState" : "manual",
"localizations" : {
@@ -12214,6 +12231,23 @@
}
}
},
"HTTP proxy authentication failed." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "HTTP代理认证失败。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "HTTP代理驗證失敗。"
}
}
}
},
"HTTP uploads are not available." : {
"extractionState" : "manual",
"localizations" : {
@@ -18981,33 +19015,53 @@
}
},
"Proxy authentication required" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "需要代理认证" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "需要代理驗證" } }
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "需要代理认证"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "需要代理驗證"
}
}
}
},
"Proxy connected" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "代理已连接" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "代理已連線" } }
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "代理已连接"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "代理已連線"
}
}
}
},
"Proxy for %@" : {
"localizations" : {
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "%@的代理" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "%@的代理" } }
}
},
"Connecting to proxy" : {
"localizations" : {
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在连接代理" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "正在連線代理" } }
}
},
"Waiting for proxy authentication" : {
"localizations" : {
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在等待代理认证" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "正在等待代理驗證" } }
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@的代理"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@的代理"
}
}
}
},
"Proxy Hostname" : {
@@ -20100,23 +20154,6 @@
}
}
},
"Remote Port" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "远程端口"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "遠端連接埠"
}
}
}
},
"Remote path" : {
"extractionState" : "stale",
"localizations" : {
@@ -20150,6 +20187,23 @@
}
}
},
"Remote Port" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "远程端口"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "遠端連接埠"
}
}
}
},
"Remove" : {
"extractionState" : "manual",
"localizations" : {
@@ -26036,24 +26090,6 @@
}
}
},
"HTTP proxy authentication failed." : {
"localizations" : {
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "HTTP代理认证失败。" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "HTTP代理驗證失敗。" } }
}
},
"The selected Identity cannot be updated on this device." : {
"localizations" : {
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "无法在此设备上更新所选身份。" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "無法在此裝置上更新所選身分。" } }
}
},
"The SOCKS5 proxy rejected authentication." : {
"localizations" : {
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "SOCKS5代理拒绝了认证。" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "SOCKS5代理拒絕了驗證。" } }
}
},
"The proxy configuration is incomplete." : {
"extractionState" : "stale",
"localizations" : {
@@ -26342,6 +26378,23 @@
}
}
},
"The selected Identity cannot be updated on this device." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "无法在此设备上更新所选身份。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "無法在此裝置上更新所選身分。"
}
}
}
},
"The selected identity has no usable password or private key." : {
"extractionState" : "stale",
"localizations" : {
@@ -26660,6 +26713,23 @@
}
}
},
"The SOCKS5 proxy rejected authentication." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "SOCKS5代理拒绝了认证。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "SOCKS5代理拒絕了驗證。"
}
}
}
},
"The source file changed. Start a new transfer." : {
"extractionState" : "manual",
"localizations" : {
@@ -29801,6 +29871,23 @@
}
}
},
"Waiting for proxy authentication" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "正在等待代理认证"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "正在等待代理驗證"
}
}
}
},
"Waiting for response" : {
"extractionState" : "stale",
"localizations" : {
@@ -30592,4 +30679,4 @@
}
},
"version" : "1.2"
}
}
@@ -2271,8 +2271,11 @@ public final class ConversationModel: Identifiable {
let draft = userInputDraft(for: question.id)
switch question.kind {
case .secret:
var bytes = Data(secretInputDraft.utf8)
secretInputDraft = ""
return AgentUserInputAnswer(
questionID: question.id, value: .secret(secretInputDraft)
questionID: question.id,
value: .secret(AgentSecret(consumingUTF8: &bytes))
)
case .text:
return AgentUserInputAnswer(questionID: question.id, value: .text(draft.text))
+3 -12
View File
@@ -44,17 +44,8 @@ public nonisolated final class SSHSecret: @unchecked Sendable {
/// Copies a freshly read UTF-8 buffer into wipeable ownership, then clears
/// the source buffer before returning. The final allocation includes a NUL
/// terminator without asking `Data` to grow or reallocate its plaintext.
public convenience init(consumingUTF8 bytes: inout Data) {
self.init(consumingUTF8: &bytes, range: bytes.startIndex..<bytes.endIndex)
}
/// Copies only `range` into wipeable ownership, then clears the complete
/// source buffer. This lets versioned secret envelopes discard their header
/// without allocating an intermediate plaintext `Data` value.
public init(consumingUTF8 bytes: inout Data, range: Range<Data.Index>) {
precondition(range.lowerBound >= bytes.startIndex && range.upperBound <= bytes.endIndex)
let offset = bytes.distance(from: bytes.startIndex, to: range.lowerBound)
let count = bytes.distance(from: range.lowerBound, to: range.upperBound)
public init(consumingUTF8 bytes: inout Data) {
let count = bytes.count
let pointer = UnsafeMutableRawPointer.allocate(
byteCount: count + 1,
alignment: MemoryLayout<UInt8>.alignment
@@ -62,7 +53,7 @@ public nonisolated final class SSHSecret: @unchecked Sendable {
if count > 0 {
bytes.withUnsafeBytes { raw in
guard let source = raw.baseAddress else { return }
pointer.copyMemory(from: source.advanced(by: offset), byteCount: count)
pointer.copyMemory(from: source, byteCount: count)
}
}
pointer.storeBytes(of: UInt8(0), toByteOffset: count, as: UInt8.self)
@@ -0,0 +1,8 @@
import Darwin
nonisolated extension SecretMemory {
public static func clear(_ pointer: UnsafeMutableRawPointer, count: Int) {
guard count > 0 else { return }
_ = memset_s(pointer, count, 0, count)
}
}
@@ -353,7 +353,9 @@ nonisolated public struct SSHExecTool: AgentToolDefinition, AgentToolSchemaBuild
let arguments = try Arguments(invocation)
let hostID = try arguments.uuid("host_id")
let command = try arguments.string("command")
var input = arguments.optionalString("stdin").map { Data($0.utf8) }
var input = arguments.optionalString("stdin").map {
SSHCommandInput.plain(Data($0.utf8))
}
if let rawHandle = arguments.optionalString("secret_handle") {
guard let handleID = UUID(uuidString: rawHandle) else {
throw AgentToolError.invalidArguments(
@@ -363,7 +365,7 @@ nonisolated public struct SSHExecTool: AgentToolDefinition, AgentToolSchemaBuild
)
}
let purpose = try arguments.string("stdin_purpose")
let secret: String
let secret: AgentSecret
do {
secret = try await context.secretBroker.consume(
SecretHandle(id: handleID),
@@ -383,7 +385,7 @@ nonisolated public struct SSHExecTool: AgentToolDefinition, AgentToolSchemaBuild
+ "Call request_user_secret again with matching values."
)
}
input = Data(secret.utf8)
input = .secret(secret)
}
// Live output, so a command that takes a minute is a card
// filling in rather than a spinner. Reported, never returned:
@@ -1284,7 +1284,7 @@ public actor AgentConnectionPool {
/// the one-connection policy and is surfaced; anything that failed after the
/// command started is also surfaced because replay could duplicate effects.
public func execute(
_ command: String, workingDirectory: String?, stdin: Data?,
_ command: String, workingDirectory: String?, stdin: SSHCommandInput?,
timeout: TimeInterval, outputLimit: Int, hostID: UUID,
configuration override: RequestResolving? = nil,
onOutput: SSHExecOutputHandler? = nil
@@ -1,3 +1,4 @@
import AgentKit
import Foundation
import Logging
import TermindApplication
@@ -23,6 +24,27 @@ public nonisolated struct SSHExecResult: Hashable, Sendable {
}
}
public nonisolated enum SSHCommandInput: Sendable {
case plain(Data)
case secret(AgentSecret)
func append(to queue: SSHByteQueue) throws {
switch self {
case .plain(var bytes):
if !bytes.isEmpty, bytes.last != 0x0A { bytes.append(0x0A) }
queue.append(bytes)
case .secret(let secret):
defer { secret.clear() }
try secret.withUnsafeBytes { bytes in
queue.append(bytes)
if !bytes.isEmpty, bytes.last != 0x0A {
queue.append(contentsOf: [0x0A])
}
}
}
}
}
/// The one failure that is safe to retry on a replacement connection. It is
/// emitted only before the remote process has started.
public nonisolated enum SSHCommandExecutionError: LocalizedError, Sendable {
@@ -44,7 +66,7 @@ public nonisolated protocol SSHConnectionProtocol: AnyObject, Sendable {
func connect(using configuration: SSHConnectionConfiguration) async throws
func connect(using request: SSHConnectionRequest) async throws
func execute(
_ command: String, workingDirectory: String?, stdin: Data?,
_ command: String, workingDirectory: String?, stdin: SSHCommandInput?,
timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler?
) async throws -> SSHExecResult
func openSFTPChannel() async throws -> any SFTPChannelProtocol
@@ -94,7 +116,7 @@ public actor SSHConnection: SSHConnectionProtocol {
}
public func execute(
_ command: String, workingDirectory: String?, stdin: Data?,
_ command: String, workingDirectory: String?, stdin: SSHCommandInput?,
timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler?
) async throws -> SSHExecResult {
let cancellation = TransferCancellationToken()
@@ -37,7 +37,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
let onOutput: SSHExecOutputHandler?
let continuation: CheckedContinuation<SSHExecResult, any Error>
let channelLease: SSHChannelLease
let input = SSHByteQueue(chunkSize: 32_700)
let input = SSHByteQueue(chunkSize: 32_700, zeroizesConsumedBytes: true)
var phase = Phase.opening
var execChannel: OpaquePointer?
var sentEOF = false
@@ -52,11 +52,11 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
var completed = false
init(
command: String, workingDirectory: String?, stdin: Data?, timeout: TimeInterval,
command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval,
outputLimit: Int, cancellation: TransferCancellationToken,
onOutput: SSHExecOutputHandler?, channelLease: SSHChannelLease,
continuation: CheckedContinuation<SSHExecResult, any Error>
) {
) throws {
let effective: String
if let cwd = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), !cwd.isEmpty {
effective = "cd -- \(Self.shellQuote(cwd)) && /bin/sh -lc \(Self.shellQuote(command))"
@@ -70,9 +70,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
self.onOutput = onOutput
self.channelLease = channelLease
self.continuation = continuation
var input = stdin ?? Data()
if !input.isEmpty, input.last != 0x0A { input.append(0x0A) }
self.input.append(input)
try stdin?.append(to: input)
sentEOF = input.isEmpty
}
@@ -178,7 +176,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
}
func execute(
_ command: String, workingDirectory: String?, stdin: Data?, timeout: TimeInterval,
_ command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval,
outputLimit: Int, cancellation: TransferCancellationToken, onOutput: SSHExecOutputHandler?
) async throws -> SSHExecResult {
guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
@@ -206,10 +204,17 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
throwing: SSHCommandExecutionError.connectionUnavailable(TermindSSHError.disconnected))
return
}
let context = SSHExecChannel(
command: command, workingDirectory: workingDirectory, stdin: stdin,
timeout: timeout, outputLimit: outputLimit, cancellation: cancellation,
onOutput: onOutput, channelLease: channelLease, continuation: continuation)
let context: SSHExecChannel
do {
context = try SSHExecChannel(
command: command, workingDirectory: workingDirectory, stdin: stdin,
timeout: timeout, outputLimit: outputLimit, cancellation: cancellation,
onOutput: onOutput, channelLease: channelLease, continuation: continuation)
} catch {
channelLease.release()
continuation.resume(throwing: error)
return
}
commands[context.id] = context
pendingOpens.insert(context.id)
schedulePump()
@@ -165,7 +165,8 @@ public actor SSHMetricsSampler: MetricsSampling {
let transportLimit = outputLimit.addingReportingOverflow(Self.framingAllowance)
let raw = try await adapter.withConnection { connection in
try await connection.execute(
framedCommand, workingDirectory: workingDirectory, stdin: stdin,
framedCommand, workingDirectory: workingDirectory,
stdin: stdin.map(SSHCommandInput.plain),
timeout: timeout, outputLimit: transportLimit.overflow ? outputLimit : transportLimit.partialValue,
onOutput: onOutput)
}
@@ -927,7 +927,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
#"umask 077; dir="$HOME/.ssh"; file="$dir/authorized_keys"; mkdir -p "$dir" && touch "$file" && chmod 700 "$dir" && chmod 600 "$file" && IFS= read -r comment && IFS= read -r key && { LC_ALL=C grep -Fqx -- "$key" "$file" || printf '%s\n%s\n' "$comment" "$key" >> "$file"; }"#
for input in inputs {
let result = try await connection.execute(
command, workingDirectory: nil, stdin: input, timeout: 20,
command, workingDirectory: nil, stdin: .plain(input), timeout: 20,
outputLimit: 64 * 1024, onOutput: nil)
guard result.exitCode == 0 else {
let detail = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1,6 +1,7 @@
import CSSH2
import Darwin
import Foundation
import TermindApplication
/// Capacity and scheduling are independent: a larger receive allowance never
/// grants one flow a larger turn or reserves additional queue memory.
@@ -38,24 +39,31 @@ nonisolated struct SSHForwardingTuning: Sendable {
/// new socket data arrives while an earlier write is blocked.
nonisolated final class SSHByteQueue {
let chunkSize: Int
let zeroizesConsumedBytes: Bool
init(chunkSize: Int = 32 * 1024) {
init(chunkSize: Int = 32 * 1024, zeroizesConsumedBytes: Bool = false) {
precondition(chunkSize > 0)
self.chunkSize = chunkSize
self.zeroizesConsumedBytes = zeroizesConsumedBytes
}
private final class Chunk {
let bytes: UnsafeMutableRawPointer
let size: Int
let zeroizesOnRelease: Bool
var offset = 0
init(_ source: UnsafeRawBufferPointer) {
init(_ source: UnsafeRawBufferPointer, zeroizesOnRelease: Bool) {
size = source.count
self.zeroizesOnRelease = zeroizesOnRelease
bytes = .allocate(byteCount: size, alignment: 1)
bytes.copyMemory(from: source.baseAddress!, byteCount: size)
}
deinit { bytes.deallocate() }
deinit {
if zeroizesOnRelease { SecretMemory.clear(bytes, count: size) }
bytes.deallocate()
}
}
private var chunks: [Chunk?] = []
@@ -70,7 +78,12 @@ nonisolated final class SSHByteQueue {
var offset = 0
while offset < bytes.count {
let end = min(offset + chunkSize, bytes.count)
chunks.append(Chunk(UnsafeRawBufferPointer(rebasing: bytes[offset..<end])))
chunks.append(
Chunk(
UnsafeRawBufferPointer(rebasing: bytes[offset..<end]),
zeroizesOnRelease: zeroizesConsumedBytes
)
)
offset = end
}
count += bytes.count
@@ -124,6 +137,9 @@ nonisolated final class SSHByteQueue {
while remaining > 0 {
let chunk = chunks[head]!
let consumed = min(remaining, chunk.size - chunk.offset)
if zeroizesConsumedBytes {
SecretMemory.clear(chunk.bytes + chunk.offset, count: consumed)
}
chunk.offset += consumed
remaining -= consumed
if chunk.offset == chunk.size {
@@ -30,8 +30,6 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
private static let passwordService = "com.akinokaede.termind.passwords.v1"
private static let softwareKeyService = "com.akinokaede.termind.ssh-software-keys.v1"
private static let enclaveService = "com.akinokaede.termind.secure-enclave-handles.v1"
private static let softwareKeyMagic = Data("termind-ssh-key\0".utf8)
private static let softwareKeyFormatVersion: UInt8 = 0x01
private let accessGroup: String
@@ -92,14 +90,14 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
{
try validate(account, prefix: "termind.key.")
guard
var data = try data(
let data = try data(
for: account, service: service(for: storage),
synchronizable: storage == .iCloud
)
else { return nil }
switch storage {
case .thisDevice, .iCloud:
return .software(try Self.decodeSoftwareKey(from: &data))
return .software(try JSONDecoder().decode(SSHPrivateKeySecret.self, from: data))
case .secureEnclave:
guard !data.isEmpty else { throw KeychainSecretError.unexpectedValue }
return .secureEnclave(SecureEnclaveKeyCredential(dataRepresentation: data))
@@ -127,7 +125,7 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
defer { SecretMemory.clear(&data) }
switch storage {
case .thisDevice, .iCloud:
return .software(try Self.decodeSoftwareKeyRuntimeSecret(from: &data))
return .software(try Self.decodePrivateKeySecret(from: &data))
case .secureEnclave:
guard !data.isEmpty else { throw KeychainSecretError.unexpectedValue }
return .secureEnclave(SecureEnclaveKeyCredential(dataRepresentation: data))
@@ -141,7 +139,7 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
var data: Data?
switch (value, storage) {
case (.software(let secret), .thisDevice), (.software(let secret), .iCloud):
data = Self.encodeSoftwareKey(secret)
data = try JSONEncoder().encode(secret)
case (.secureEnclave(let credential), .secureEnclave):
data = credential.dataRepresentation
case (nil, _):
@@ -161,50 +159,11 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
)
}
static func encodeSoftwareKey(_ secret: SSHPrivateKeySecret) -> Data {
var data = Data()
data.reserveCapacity(Self.softwareKeyMagic.count + 1 + secret.privateKey.utf8.count)
data.append(Self.softwareKeyMagic)
data.append(Self.softwareKeyFormatVersion)
data.append(contentsOf: secret.privateKey.utf8)
return data
}
static func decodeSoftwareKey(from data: inout Data) throws -> SSHPrivateKeySecret {
defer { SecretMemory.clear(&data) }
if let payload = try currentSoftwareKeyPayload(in: data) {
guard let privateKey = String(data: data[payload], encoding: .utf8) else {
throw KeychainSecretError.unexpectedValue
}
return SSHPrivateKeySecret(privateKey: privateKey)
}
do {
return try JSONDecoder().decode(SSHPrivateKeySecret.self, from: data)
} catch {
throw KeychainSecretError.unexpectedValue
}
}
/// Decodes the current envelope directly into one-shot ownership. The
/// legacy JSON path remains strict and never materializes the private key as
/// an immutable Swift `String`.
static func decodeSoftwareKeyRuntimeSecret(from data: inout Data) throws -> SSHSecret {
defer { SecretMemory.clear(&data) }
if let payload = try currentSoftwareKeyPayload(in: data) {
guard isValidUTF8(data, in: payload) else {
throw KeychainSecretError.unexpectedValue
}
return SSHSecret(consumingUTF8: &data, range: payload)
}
return try decodeLegacySoftwareKeyRuntimeSecret(from: &data)
}
/// Strictly decodes the legacy single-field JSON envelope without creating
/// an immutable Swift `String` containing the private key. Corrupted shapes
/// fail closed instead of being searched for a matching field substring.
private static func decodeLegacySoftwareKeyRuntimeSecret(from data: inout Data) throws
-> SSHSecret
{
static func decodePrivateKeySecret(from data: inout Data) throws -> SSHSecret {
defer { SecretMemory.clear(&data) }
var index = data.startIndex
func skipWhitespace() {
while index < data.endIndex, [0x20, 0x09, 0x0A, 0x0D].contains(data[index]) {
@@ -241,9 +200,6 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
guard index == data.endIndex else {
throw KeychainSecretError.unexpectedValue
}
guard isValidUTF8(result, in: result.startIndex..<result.endIndex) else {
throw KeychainSecretError.unexpectedValue
}
return SSHSecret(consumingUTF8: &result)
}
guard byte == 0x5C else {
@@ -281,74 +237,6 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
throw KeychainSecretError.unexpectedValue
}
/// Returns `nil` only for a legacy value. Once any complete or truncated
/// current-format magic is present, malformed data fails closed.
private static func currentSoftwareKeyPayload(in data: Data) throws -> Range<Data.Index>? {
if data.starts(with: Self.softwareKeyMagic) {
let versionIndex = data.index(data.startIndex, offsetBy: Self.softwareKeyMagic.count)
guard versionIndex < data.endIndex else { throw KeychainSecretError.unexpectedValue }
guard data[versionIndex] == Self.softwareKeyFormatVersion else {
throw KeychainSecretError.unexpectedValue
}
let payloadStart = data.index(after: versionIndex)
guard payloadStart < data.endIndex else { throw KeychainSecretError.unexpectedValue }
return payloadStart..<data.endIndex
}
if !data.isEmpty && Self.softwareKeyMagic.starts(with: data) {
throw KeychainSecretError.unexpectedValue
}
return nil
}
private static func isValidUTF8(_ data: Data, in range: Range<Data.Index>) -> Bool {
var index = range.lowerBound
func continuation() -> UInt8? {
guard index < range.upperBound else { return nil }
let byte = data[index]
guard byte >= 0x80 && byte <= 0xBF else { return nil }
index = data.index(after: index)
return byte
}
while index < range.upperBound {
let first = data[index]
index = data.index(after: index)
switch first {
case 0x00...0x7F:
continue
case 0xC2...0xDF:
guard continuation() != nil else { return false }
case 0xE0:
guard let second = continuation(), second >= 0xA0, continuation() != nil else {
return false
}
case 0xE1...0xEC, 0xEE...0xEF:
guard continuation() != nil, continuation() != nil else { return false }
case 0xED:
guard let second = continuation(), second <= 0x9F, continuation() != nil else {
return false
}
case 0xF0:
guard
let second = continuation(), second >= 0x90,
continuation() != nil, continuation() != nil
else { return false }
case 0xF1...0xF3:
guard
continuation() != nil, continuation() != nil, continuation() != nil
else { return false }
case 0xF4:
guard
let second = continuation(), second <= 0x8F,
continuation() != nil, continuation() != nil
else { return false }
default:
return false
}
}
return true
}
private static func hex(_ byte: UInt8) -> UInt8? {
switch byte {
case 0x30...0x39: byte - 0x30
+5 -3
View File
@@ -103,9 +103,7 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r
leaving retry state; retry/session/Agent/Metrics/transfer state retains only reentry flags and non-secret Identity/Key
provenance. Saved material is reread from Keychain for another physical connection; temporary Passwords and every
Passphrase must be entered again. UI authentication fields clear synchronously on submission. Availability checks query
Keychain metadata without reading secret data. New software Keys use a magic- and version-prefixed UTF-8 envelope that
moves its payload directly into one-shot ownership; the legacy JSON envelope remains read-only, is parsed strictly and
fails closed.
Keychain metadata without reading secret data; the legacy software-Key JSON envelope is parsed strictly and fails closed.
- Saved iCloud Passwords and software Keys cannot be moved to device-only storage: editors disable that destination and
application writes reject it before touching Keychain. New credentials may start device-only, and device-only credentials
may move to iCloud.
@@ -331,6 +329,10 @@ factories/Sendable buffer handlers. Timers/workers enter explicit short MainActo
SFTP. Standalone Assistant can discover Hosts without sidebar attachment; ask only for ambiguity. App auth handles SSH prerequisites;
`request_user_secret` is for command secrets. Suggestions create user-operated cards, never execute. Deterministic provider schemas/cache
keys; cached tokens never double-count usage.
- `request_user_secret` converts the UI's unavoidable transient `String` into AgentKit-owned `AgentSecret` memory at submission. Its broker
transfers one-shot ownership without plaintext `String` copies; `ssh_exec` copies borrowed bytes directly into a zeroizing stdin queue
that wipes sent prefixes and all remaining bytes on teardown. AgentKit and Termind deliberately keep separate `SecretMemory`
implementations so neither package owns the other's security boundary.
- Concurrent batches require every call parallel; preflight may tighten (`ssh_exec` proven-safe only, destructive MCP sequential). Answers stay
model-ordered, including refusals/cancellation. Questions/approvals queue singly; hooks cannot remove structural authorization. Command
classification unwraps bounded known syntax; unknown/complex/unsupported input fails closed as state-changing.
+2 -2
View File
@@ -54,7 +54,7 @@ TERMIND_MARKETING_RUNTIME=com.apple.CoreSimulator.SimRuntime.iOS-27-0 \
```
Recapture affected scenes in **every locale**, regenerate matching frames/boards only. Vault/Metrics/Assistant map to mobile
boards 04–06; SSH/SFTP to 02–03. First boards additionally depend on iPhone SSH and iPad SFTP, so those source changes affect
boards 04–06; SSH/SFTP to 02–03. First boards additionally depend on iPhone Assistant and iPad SFTP, so those source changes affect
all nine first boards.
### Mac
@@ -112,7 +112,7 @@ iPad 13-inch 2752×2064.
| Board | iPhone / iPad | Mac |
| --- | --- | --- |
| 01 | Mac Workspace + iPhone SSH + iPad SFTP | same |
| 01 | Mac Workspace + iPhone Assistant + iPad SFTP | same |
| 02 | SSH | Workspace |
| 03 | SFTP | Metrics |
| 04 | Metrics | Assistant |
@@ -302,11 +302,14 @@ private struct ExternalSecretRequestForm: View {
private func submitSecret() {
guard let question, !secret.isEmpty else { return }
let value = secret
var bytes = Data(secret.utf8)
secret = ""
submit(
.answered([
AgentUserInputAnswer(questionID: question.id, value: .secret(value))
AgentUserInputAnswer(
questionID: question.id,
value: .secret(AgentSecret(consumingUTF8: &bytes))
)
]))
}
+1 -1
View File
@@ -395,7 +395,7 @@ private actor StubSSHConnection: SSHConnectionProtocol {
func connect(using configuration: SSHConnectionConfiguration) async throws {}
func execute(
_ command: String, workingDirectory: String?, stdin: Data?,
_ command: String, workingDirectory: String?, stdin: SSHCommandInput?,
timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler?
) async throws -> SSHExecResult {
executeCount += 1
+1 -1
View File
@@ -1401,7 +1401,7 @@ private actor AuthenticationSSHConnection: SSHConnectionProtocol {
func connect(using configuration: SSHConnectionConfiguration) async throws {}
func execute(
_ command: String, workingDirectory: String?, stdin: Data?,
_ command: String, workingDirectory: String?, stdin: SSHCommandInput?,
timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler?
) async throws -> SSHExecResult {
SSHExecResult(
+3 -2
View File
@@ -943,7 +943,7 @@ struct AgentRuntimeTests {
let binding = SecretBinding(
runID: UUID(), toolName: "ssh_exec", hostID: UUID(), purpose: "sudo"
)
let handle = await broker.issue("password", binding: binding)
let handle = try await broker.issue(agentSecret("password"), binding: binding)
await #expect(throws: SecretBrokerError.bindingMismatch) {
try await broker.consume(
@@ -954,7 +954,8 @@ struct AgentRuntimeTests {
)
)
}
#expect(try await broker.consume(handle, matching: binding) == "password")
let consumed = try await broker.consume(handle, matching: binding)
#expect(try consumed.withUnsafeBytes { Array($0) } == Array("password".utf8))
await #expect(throws: SecretBrokerError.notFound) {
try await broker.consume(handle, matching: binding)
}
+1 -1
View File
@@ -1139,7 +1139,7 @@ private actor StubTransferSSHConnection: SSHConnectionProtocol {
func connect(using configuration: SSHConnectionConfiguration) async throws {}
func execute(
_ command: String, workingDirectory: String?, stdin: Data?, timeout: TimeInterval,
_ command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval,
outputLimit: Int, onOutput: SSHExecOutputHandler?
) async throws -> SSHExecResult {
throw AgentToolError.invalidArguments("Unexpected exec in scratch transfer test")
+7
View File
@@ -0,0 +1,7 @@
import AgentKit
import Foundation
func agentSecret(_ value: String) -> AgentSecret {
var bytes = Data(value.utf8)
return AgentSecret(consumingUTF8: &bytes)
}
+15 -45
View File
@@ -36,69 +36,29 @@ struct AuthorizedKeyExportTests {
)
}
@Test
func softwareKeyEnvelopeRoundTripsWithoutKeepingTheSourceBuffer() throws {
let privateKey = "-----BEGIN TEST-----\nsecret-钥匙\n-----END TEST-----"
let stored = SSHPrivateKeySecret(privateKey: privateKey)
var encoded = KeychainSecretStore.encodeSoftwareKey(stored)
#expect(encoded.starts(with: Data("termind-ssh-key\0".utf8) + Data([0x01])))
let decoded = try KeychainSecretStore.decodeSoftwareKey(from: &encoded)
#expect(encoded.isEmpty)
#expect(decoded == stored)
var runtimeEncoded = KeychainSecretStore.encodeSoftwareKey(stored)
let runtime = try KeychainSecretStore.decodeSoftwareKeyRuntimeSecret(from: &runtimeEncoded)
#expect(runtimeEncoded.isEmpty)
#expect(runtime.matches(privateKey))
}
@Test
func legacyPrivateKeyEnvelopeRequiresACompleteJSONObject() throws {
var encoded = try JSONEncoder().encode(
SSHPrivateKeySecret(privateKey: "-----BEGIN TEST-----\nsecret\n-----END TEST-----")
)
let secret = try KeychainSecretStore.decodeSoftwareKeyRuntimeSecret(from: &encoded)
let secret = try KeychainSecretStore.decodePrivateKeySecret(from: &encoded)
#expect(encoded.isEmpty)
#expect(secret.matches("-----BEGIN TEST-----\nsecret\n-----END TEST-----"))
#expect(throws: KeychainSecretError.self) {
var data = Data(#"{"privateKey":"secret""#.utf8)
_ = try KeychainSecretStore.decodeSoftwareKeyRuntimeSecret(
_ = try KeychainSecretStore.decodePrivateKeySecret(
from: &data
)
}
#expect(throws: KeychainSecretError.self) {
var data = Data(#"{"privateKey":"secret"} trailing"#.utf8)
_ = try KeychainSecretStore.decodeSoftwareKeyRuntimeSecret(
_ = try KeychainSecretStore.decodePrivateKeySecret(
from: &data
)
}
}
@Test
func currentSoftwareKeyEnvelopeRejectsMalformedValuesWithoutJSONFallback() {
let magic = Data("termind-ssh-key\0".utf8)
let malformed: [Data] = [
magic.prefix(5),
magic,
magic + Data([0x02]) + Data(#"{"privateKey":"fallback"}"#.utf8),
magic + Data([0x01]),
magic + Data([0x01, 0xC0, 0x80]),
].map(Data.init)
for value in malformed {
#expect(throws: KeychainSecretError.self) {
var data = value
_ = try KeychainSecretStore.decodeSoftwareKey(from: &data)
}
#expect(throws: KeychainSecretError.self) {
var data = value
_ = try KeychainSecretStore.decodeSoftwareKeyRuntimeSecret(from: &data)
}
}
}
@Test
func multipleKeysReuseOneAuthenticatedConnection() async throws {
let password = SSHSecret("one-use")
@@ -150,11 +110,21 @@ private actor AuthorizedKeyConnection: SSHConnectionProtocol {
}
func execute(
_ command: String, workingDirectory: String?, stdin: Data?,
_ command: String, workingDirectory: String?, stdin: SSHCommandInput?,
timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler?
) async throws -> SSHExecResult {
executions += 1
inputs.append(stdin.map { String(decoding: $0, as: UTF8.self) } ?? "")
let input: String
switch stdin {
case .plain(let bytes):
input = String(decoding: bytes, as: UTF8.self)
case .secret(let secret):
defer { secret.clear() }
input = try secret.withUnsafeBytes { String(decoding: $0, as: UTF8.self) }
case nil:
input = ""
}
inputs.append(input)
return SSHExecResult(
stdout: "", stderr: "", exitCode: 0,
stdoutTruncated: false, stderrTruncated: false
+8 -10
View File
@@ -242,15 +242,11 @@ struct ExternalAuthCoordinatorTests {
return false
}
coordinator.respondSecret(
.answered([
AgentUserInputAnswer(questionID: "secret", value: .secret("value"))
]))
#expect(
await task.value
== .answered([
AgentUserInputAnswer(questionID: "secret", value: .secret("value"))
]))
let response = AgentUserInputResponse.answered([
AgentUserInputAnswer(questionID: "secret", value: .secret(agentSecret("value")))
])
coordinator.respondSecret(response)
#expect(await task.value == response)
let ordinary = AgentUserInputRequest(
questions: [AgentUserQuestion(id: "text", prompt: "Name", kind: .text)]
@@ -338,7 +334,9 @@ struct ExternalAuthCoordinatorTests {
try await waitUntil { coordinator.current?.id == request.id }
coordinator.respondSecret(
.answered([
AgentUserInputAnswer(questionID: "secret", value: .secret("value"))
AgentUserInputAnswer(
questionID: "secret", value: .secret(agentSecret("value"))
)
]))
guard case .answered = await secret.value else {
Issue.record("expected the secret request to be answered")
+6 -4
View File
@@ -188,7 +188,9 @@ struct MCPServerToolsTests {
}
authentication.respondSecret(
.answered([
AgentUserInputAnswer(questionID: "secret", value: .secret("TOP-SECRET"))
AgentUserInputAnswer(
questionID: "secret", value: .secret(agentSecret("TOP-SECRET"))
)
]))
let result = try await call.value
@@ -206,7 +208,7 @@ struct MCPServerToolsTests {
purpose: "sudo password"
)
)
#expect(consumed == "TOP-SECRET")
#expect(try consumed.withUnsafeBytes { Array($0) } == Array("TOP-SECRET".utf8))
await #expect(throws: SecretBrokerError.notFound) {
_ = try await broker.consume(
handle,
@@ -259,8 +261,8 @@ struct MCPServerToolsTests {
let server = makeMCPServerTools(externalAuthentication: authentication)
let broker = SecretBroker()
let runID = UUID()
_ = await broker.issue(
"secret",
_ = try await broker.issue(
agentSecret("secret"),
binding: SecretBinding(
runID: runID, toolName: "ssh_exec", hostID: UUID(), purpose: "test"
)
+1 -1
View File
@@ -500,7 +500,7 @@ private actor CopySSHConnection: SSHConnectionProtocol {
func connect(using configuration: SSHConnectionConfiguration) async throws {}
func execute(
_ command: String, workingDirectory: String?, stdin: Data?, timeout: TimeInterval,
_ command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval,
outputLimit: Int, onOutput: SSHExecOutputHandler?
) async throws -> SSHExecResult {
throw AgentToolError.invalidArguments("Unexpected exec in SFTP copy test")
+22
View File
@@ -1,3 +1,4 @@
import AgentKit
import CSSH2
import Darwin
import Foundation
@@ -7,6 +8,27 @@ import Testing
@Suite
struct SSHForwardingPumpTests {
@Test
func secretCommandInputMovesIntoTheZeroizingQueueAndAddsANewline() throws {
var bytes = Data("sudo-password".utf8)
let secret = AgentSecret(consumingUTF8: &bytes)
let queue = SSHByteQueue(chunkSize: 4, zeroizesConsumedBytes: true)
try SSHCommandInput.secret(secret).append(to: queue)
#expect(secret.isConsumed)
#expect(bytes.isEmpty)
var queued: [UInt8] = []
while !queue.isEmpty {
let count = queue.withFirstBytes { bytes -> Int in
queued.append(contentsOf: bytes)
return bytes.count
}
queue.consume(count)
}
#expect(queued == Array("sudo-password\n".utf8))
}
@Test func blockedWriteKeepsItsPointerLengthAndBytesWhileMoreDataArrives() {
let queue = SSHByteQueue()
queue.append(Data(repeating: 0x31, count: 100))
+1 -1
View File
@@ -2831,7 +2831,7 @@
repositoryURL = "https://github.com/AkinoKaede/AgentKit.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.13.3;
minimumVersion = 0.14.0;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -6,8 +6,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/AkinoKaede/AgentKit.git",
"state" : {
"revision" : "591375990284bf90858e7f049c516777432970f7",
"version" : "0.13.3"
"revision" : "ffc4aa41c8854142aa47c35231e57f56f961ba9f",
"version" : "0.14.0"
}
},
{