Harden one-shot SSH secret handling
This commit is contained in:
1 parent
cb106ba15d
commit
3db8cdbf85
26 files changed
+524
-261
No files matched your search
@@ -141,7 +141,7 @@ struct SSHAuthenticationSubmissionTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func takingAttemptSecretsLeavesOnlyReentryMarkersInSessionState() {
|
||||
func takingAttemptSecretsMovesPlaintextIntoOneShotOwners() throws {
|
||||
let hostID = UUID()
|
||||
var retained = [
|
||||
hostID: SSHAuthenticationSubmission(
|
||||
@@ -150,14 +150,22 @@ struct SSHAuthenticationSubmissionTests {
|
||||
)
|
||||
]
|
||||
|
||||
let attempt = retained.takeAuthenticationSecrets()
|
||||
var attempt = retained.takeAuthenticationSecrets()
|
||||
|
||||
#expect(attempt[hostID]?.password == "one-use-password")
|
||||
#expect(attempt[hostID]?.passphrase == "one-use-passphrase")
|
||||
#expect(attempt[hostID]?.password == nil)
|
||||
#expect(attempt[hostID]?.passphrase == nil)
|
||||
#expect(retained[hostID]?.password == nil)
|
||||
#expect(retained[hostID]?.passphrase == nil)
|
||||
#expect(retained[hostID]?.requiresPasswordReentry == true)
|
||||
#expect(retained[hostID]?.requiresPassphraseReentry == true)
|
||||
|
||||
let password = try #require(attempt[hostID]?.passwordForConnection())
|
||||
let passphrase = try #require(attempt[hostID]?.passphraseForConnection())
|
||||
#expect(password.matches("one-use-password"))
|
||||
#expect(passphrase.matches("one-use-passphrase"))
|
||||
attempt[hostID]?.clearSecrets()
|
||||
#expect(password.isConsumed)
|
||||
#expect(passphrase.isConsumed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -187,10 +187,13 @@ struct SSHInteractionPolicyTests {
|
||||
}
|
||||
|
||||
@Test func runtimeSecretCanBeConsumedOnlyOnceAcrossAllReferences() throws {
|
||||
let secret = SSHSecret("one-use")
|
||||
var source = Data("one-use".utf8)
|
||||
let secret = SSHSecret(consumingUTF8: &source)
|
||||
let alias = secret
|
||||
var observedCount = 0
|
||||
|
||||
#expect(source.isEmpty)
|
||||
|
||||
try secret.withUnsafeCStringAndConsume { buffer, count in
|
||||
observedCount = count
|
||||
#expect(buffer[0] == CChar(bitPattern: Character("o").asciiValue!))
|
||||
|
||||
@@ -44,9 +44,7 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable {
|
||||
) throws -> SSHConnectionRoute
|
||||
func transportProxy(for host: Host) throws -> SSHTransportProxy?
|
||||
func sshCredentials(for identity: Identity) -> SSHCredentialResolution
|
||||
func passwordCredential(for identity: Identity) throws -> String?
|
||||
func passwordSecret(for identity: Identity) throws -> SSHSecret?
|
||||
func keyCredential(for key: SSHKeyRef) throws -> StoredSSHKeyCredential?
|
||||
func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential?
|
||||
func recordIdentityCredentialAccess(_ identityIDs: Set<Identity.ID>, at date: Date)
|
||||
func setConnectionStatus(_ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID)
|
||||
@@ -262,14 +260,6 @@ extension RemoteSessionHostStore {
|
||||
).enforcingInteractionPolicy()
|
||||
}
|
||||
|
||||
public func connectionPasswordCredential(for identity: Identity) throws -> String? {
|
||||
let password = try passwordCredential(for: identity)
|
||||
if password != nil {
|
||||
recordIdentityCredentialAccess([identity.id], at: Date())
|
||||
}
|
||||
return password
|
||||
}
|
||||
|
||||
public func connectionPasswordSecret(for identity: Identity) throws -> SSHSecret? {
|
||||
let password = try passwordSecret(for: identity)
|
||||
if password != nil {
|
||||
@@ -278,16 +268,6 @@ extension RemoteSessionHostStore {
|
||||
return password
|
||||
}
|
||||
|
||||
public func connectionKeyCredential(
|
||||
for key: SSHKeyRef, identityID: Identity.ID?
|
||||
) throws -> StoredSSHKeyCredential? {
|
||||
let credential = try keyCredential(for: key)
|
||||
if credential != nil, let identityID {
|
||||
recordIdentityCredentialAccess([identityID], at: Date())
|
||||
}
|
||||
return credential
|
||||
}
|
||||
|
||||
public func connectionKeySecretCredential(
|
||||
for key: SSHKeyRef, identityID: Identity.ID?
|
||||
) throws -> SSHResolvedKeyCredential? {
|
||||
|
||||
@@ -29,7 +29,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
|
||||
|
||||
}
|
||||
public var nodes: [Node]
|
||||
public var secrets: any SecretStoring
|
||||
public var secrets: any SSHRuntimeSecretResolving
|
||||
public var credentialAccessHandler: SSHCredentialAccessHandler?
|
||||
|
||||
public func resolve(
|
||||
@@ -112,14 +112,16 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
|
||||
identityID: identity.id,
|
||||
username: identity.username,
|
||||
permitsNone: !identity.hasSecret,
|
||||
hasEphemeralSecret: node.submission?.password != nil || node.submission?.passphrase != nil
|
||||
hasEphemeralSecret: node.submission?.hasPassword == true
|
||||
|| node.submission?.hasPassphrase == true
|
||||
)
|
||||
}
|
||||
return SSHAuthenticationReference(
|
||||
fallbackKeyID: node.submission?.keyID ?? node.key?.id,
|
||||
username: node.submission?.username ?? "",
|
||||
permitsNone: node.key == nil && node.submission?.password == nil,
|
||||
hasEphemeralSecret: node.submission?.password != nil || node.submission?.passphrase != nil
|
||||
permitsNone: node.key == nil && node.submission?.hasPassword != true,
|
||||
hasEphemeralSecret: node.submission?.hasPassword == true
|
||||
|| node.submission?.hasPassphrase == true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -132,7 +134,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
|
||||
var result: [SSHCredentials] = []
|
||||
var retrievalFailure: (any Error)?
|
||||
if let key = node.key {
|
||||
if key.requiresPassphraseEntry, node.submission?.passphrase?.isEmpty != false {
|
||||
if key.requiresPassphraseEntry, node.submission?.hasPassphrase != true {
|
||||
throw TermindSSHError.keyPassphraseRequired
|
||||
}
|
||||
let stored: SSHResolvedKeyCredential?
|
||||
@@ -149,14 +151,14 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
|
||||
result.append(
|
||||
stored.sshCredential(
|
||||
publicKey: key.publicKey, certificate: key.certificate,
|
||||
passphraseOverride: node.submission?.passphrase,
|
||||
passphraseOverride: node.submission?.passphraseForConnection(),
|
||||
requiresPassphrase: key.requiresPassphraseEntry,
|
||||
requiresUserPresence: key.requiresUserPresence
|
||||
))
|
||||
}
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
if let password = node.submission?.password {
|
||||
if let password = node.submission?.passwordForConnection() {
|
||||
result.append(.password(password))
|
||||
} else if node.submission == nil || node.submission?.identityID != nil,
|
||||
let identity = node.identity, identity.usesPassword
|
||||
@@ -215,7 +217,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
|
||||
}
|
||||
|
||||
public init(
|
||||
nodes: [Node], secrets: any SecretStoring,
|
||||
nodes: [Node], secrets: any SSHRuntimeSecretResolving,
|
||||
credentialAccessHandler: SSHCredentialAccessHandler? = nil
|
||||
) {
|
||||
self.nodes = nodes
|
||||
|
||||
@@ -39,8 +39,8 @@ public final class CredentialLibraryModel {
|
||||
public func keyExportUnavailableReason(for host: Host) -> String? {
|
||||
library.keyExportUnavailableReason(for: host)
|
||||
}
|
||||
public func sshConnectionConfiguration(for host: Host) throws -> SSHConnectionConfiguration {
|
||||
try library.sshConnectionConfiguration(for: host)
|
||||
public func prepareSSHConnection(for host: Host) throws -> SSHConnectionPreparation {
|
||||
try library.prepareSSHConnection(for: host, submissions: [:])
|
||||
}
|
||||
|
||||
public func upsert(_ key: SSHKeyRef, credential: StoredSSHKeyCredential? = nil) throws {
|
||||
|
||||
@@ -460,7 +460,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
credentials = resolution.credentials
|
||||
}
|
||||
let expectsCredential =
|
||||
submissions[host.id].map { $0.keyID != nil || $0.password != nil }
|
||||
submissions[host.id].map { $0.keyID != nil || $0.hasPassword }
|
||||
?? (identity(for: host)?.hasSecret == true)
|
||||
if credentials.isEmpty, expectsCredential { throw TermindSSHError.missingCredential }
|
||||
let expectedHostKeys = try expectedHostKeys(
|
||||
@@ -645,14 +645,16 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
identityID: resolvedIdentity.id,
|
||||
username: resolvedIdentity.username,
|
||||
permitsNone: !resolvedIdentity.hasSecret,
|
||||
hasEphemeralSecret: submission?.password != nil || submission?.passphrase != nil
|
||||
hasEphemeralSecret: submission?.hasPassword == true
|
||||
|| submission?.hasPassphrase == true
|
||||
)
|
||||
}
|
||||
return SSHAuthenticationReference(
|
||||
fallbackKeyID: submission?.keyID,
|
||||
username: submission?.username ?? "",
|
||||
permitsNone: submission?.keyID == nil && submission?.password == nil,
|
||||
hasEphemeralSecret: submission?.password != nil || submission?.passphrase != nil
|
||||
permitsNone: submission?.keyID == nil && submission?.hasPassword != true,
|
||||
hasEphemeralSecret: submission?.hasPassword == true
|
||||
|| submission?.hasPassphrase == true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -662,9 +664,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
) throws -> [SSHCredentials] {
|
||||
var credentials: [SSHCredentials] = []
|
||||
if let keyID = submission.keyID, let key = key(keyID) {
|
||||
if key.requiresPassphraseEntry,
|
||||
submission.passphrase?.isEmpty != false
|
||||
{
|
||||
if key.requiresPassphraseEntry, !submission.hasPassphrase {
|
||||
throw TermindSSHError.keyPassphraseRequired
|
||||
}
|
||||
if let stored = try keySecretCredential(for: key) {
|
||||
@@ -674,14 +674,14 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
credentials.append(
|
||||
stored.sshCredential(
|
||||
publicKey: key.publicKey, certificate: key.certificate,
|
||||
passphraseOverride: submission.passphrase,
|
||||
passphraseOverride: submission.passphraseForConnection(),
|
||||
requiresPassphrase: key.requiresPassphraseEntry,
|
||||
requiresUserPresence: key.requiresUserPresence
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
if let password = submission.password {
|
||||
if let password = submission.passwordForConnection() {
|
||||
credentials.append(.password(password))
|
||||
} else if let identityID = submission.identityID,
|
||||
let identity = identity(identityID), identity.usesPassword,
|
||||
@@ -1233,7 +1233,9 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
public func passwordAvailability(for identity: Identity) -> SecretAvailability {
|
||||
guard identity.usesPassword else { return .notConfigured }
|
||||
do {
|
||||
return try passwordCredential(for: identity) == nil ? .missing : .available
|
||||
return try keychain.containsString(
|
||||
for: identity.credentialRef, storage: identity.passwordStorage
|
||||
) ? .available : .missing
|
||||
} catch {
|
||||
return .failed(error.localizedDescription)
|
||||
}
|
||||
@@ -1415,7 +1417,9 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
|
||||
public func keyAvailability(for key: SSHKeyRef) -> SecretAvailability {
|
||||
do {
|
||||
return try keyCredential(for: key) == nil ? .missing : .available
|
||||
return try keychain.containsKeyCredential(
|
||||
for: key.credentialRef, storage: key.storage
|
||||
) ? .available : .missing
|
||||
} catch {
|
||||
return .failed(error.localizedDescription)
|
||||
}
|
||||
|
||||
@@ -669,23 +669,16 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
|
||||
server.collectionState = .connecting
|
||||
server.isConnecting = true
|
||||
let route = hosts.route(for: host)
|
||||
if let passwordHost = route.first(where: { routeHost in
|
||||
submissions[server.id]?[routeHost.id]?.requiresPasswordReentry == true
|
||||
}) {
|
||||
if let passwordHost = submissions[server.id]?.firstHostRequiringPassword(in: route) {
|
||||
throw TermindSSHError.routeHost(
|
||||
hostID: passwordHost.id, authentication: true,
|
||||
serverChallenge: false, hostKey: false,
|
||||
message: TermindSSHError.missingCredential.localizedDescription
|
||||
)
|
||||
}
|
||||
if let passphraseHost = route.first(where: { routeHost in
|
||||
guard let submission = submissions[server.id]?[routeHost.id],
|
||||
submission.passphrase == nil,
|
||||
let keyID = submission.keyID,
|
||||
let key = hosts.key(keyID)
|
||||
else { return false }
|
||||
return key.requiresPassphraseEntry
|
||||
}) {
|
||||
if let passphraseHost = submissions[server.id]?.firstHostRequiringPassphrase(
|
||||
in: route, key: hosts.key
|
||||
) {
|
||||
throw TermindSSHError.routeHost(
|
||||
hostID: passphraseHost.id, authentication: true,
|
||||
serverChallenge: false, hostKey: false,
|
||||
|
||||
@@ -44,6 +44,16 @@ public nonisolated enum SSHHostKeyDecision: Equatable, Sendable {
|
||||
public typealias SSHHostKeyAuthorizer = @Sendable (SSHHostKeyChallenge) async throws -> SSHHostKeyDecision
|
||||
public typealias SSHAdditionalConnectionRequestResolver = @MainActor @Sendable () throws -> SSHConnectionRequest
|
||||
|
||||
public nonisolated struct SSHAuthorizedKeyInstall: Hashable, Sendable {
|
||||
public var publicKey: String
|
||||
public var name: String
|
||||
|
||||
public init(publicKey: String, name: String) {
|
||||
self.publicKey = publicKey
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
|
||||
public nonisolated enum SSHHostTrustPolicy: Equatable, Sendable {
|
||||
case requireKnownHost
|
||||
case allowChallenge
|
||||
@@ -263,7 +273,7 @@ public nonisolated enum SSHResolvedKeyCredential: Sendable {
|
||||
case secureEnclave(SecureEnclaveKeyCredential)
|
||||
|
||||
public func sshCredential(
|
||||
publicKey: String, certificate: String = "", passphraseOverride: String? = nil,
|
||||
publicKey: String, certificate: String = "", passphraseOverride: SSHSecret? = nil,
|
||||
requiresPassphrase: Bool = false, requiresUserPresence: Bool = false
|
||||
) -> SSHCredentials {
|
||||
switch self {
|
||||
@@ -273,7 +283,7 @@ public nonisolated enum SSHResolvedKeyCredential: Sendable {
|
||||
publicKey: certificate.isEmpty
|
||||
? (publicKey.isEmpty ? nil : publicKey)
|
||||
: certificate,
|
||||
passphrase: passphraseOverride.map(SSHSecret.init),
|
||||
passphrase: passphraseOverride,
|
||||
requiresPassphrase: requiresPassphrase
|
||||
)
|
||||
case .secureEnclave(let credential):
|
||||
@@ -287,7 +297,7 @@ public nonisolated enum SSHResolvedKeyCredential: Sendable {
|
||||
|
||||
nonisolated extension StoredSSHKeyCredential {
|
||||
public func sshCredential(
|
||||
publicKey: String, certificate: String = "", passphraseOverride: String? = nil,
|
||||
publicKey: String, certificate: String = "", passphraseOverride: SSHSecret? = nil,
|
||||
requiresPassphrase: Bool = false, requiresUserPresence: Bool = false
|
||||
) -> SSHCredentials {
|
||||
switch self {
|
||||
|
||||
@@ -13,7 +13,7 @@ public nonisolated enum SSHRouteEvent: Hashable, Sendable {
|
||||
|
||||
public typealias SSHRouteProgressHandler = @Sendable (SSHRouteEvent) -> Void
|
||||
|
||||
public nonisolated struct SSHAuthenticationSubmission: Hashable, Sendable {
|
||||
public nonisolated struct SSHAuthenticationSubmission: Sendable {
|
||||
public var username: String
|
||||
public var password: String?
|
||||
public var passphrase: String?
|
||||
@@ -21,6 +21,8 @@ public nonisolated struct SSHAuthenticationSubmission: Hashable, Sendable {
|
||||
public var identityID: Identity.ID? = nil
|
||||
public var requiresPasswordReentry = false
|
||||
public var requiresPassphraseReentry = false
|
||||
private var runtimePassword: SSHSecret?
|
||||
private var runtimePassphrase: SSHSecret?
|
||||
public init(
|
||||
username: String, password: String? = nil, passphrase: String? = nil, keyID: SSHKeyRef.ID? = nil,
|
||||
identityID: Identity.ID? = nil
|
||||
@@ -32,22 +34,72 @@ public nonisolated struct SSHAuthenticationSubmission: Hashable, Sendable {
|
||||
self.identityID = identityID
|
||||
}
|
||||
|
||||
public var hasPassword: Bool { password != nil || runtimePassword != nil }
|
||||
public var hasPassphrase: Bool {
|
||||
passphrase?.isEmpty == false || runtimePassphrase != nil
|
||||
}
|
||||
|
||||
public func passwordForConnection() -> SSHSecret? {
|
||||
runtimePassword ?? password.map(SSHSecret.init)
|
||||
}
|
||||
|
||||
public func passphraseForConnection() -> SSHSecret? {
|
||||
runtimePassphrase ?? passphrase.map(SSHSecret.init)
|
||||
}
|
||||
|
||||
public mutating func clearSecrets() {
|
||||
if password != nil { requiresPasswordReentry = true }
|
||||
if passphrase != nil { requiresPassphraseReentry = true }
|
||||
if hasPassword { requiresPasswordReentry = true }
|
||||
if hasPassphrase { requiresPassphraseReentry = true }
|
||||
password = nil
|
||||
passphrase = nil
|
||||
runtimePassword?.clear()
|
||||
runtimePassword = nil
|
||||
runtimePassphrase?.clear()
|
||||
runtimePassphrase = nil
|
||||
}
|
||||
|
||||
fileprivate mutating func prepareForConnectionAttempt() {
|
||||
if let password {
|
||||
runtimePassword = SSHSecret(password)
|
||||
self.password = nil
|
||||
}
|
||||
if let passphrase {
|
||||
if !passphrase.isEmpty {
|
||||
runtimePassphrase = SSHSecret(passphrase)
|
||||
}
|
||||
self.passphrase = nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
nonisolated extension Dictionary where Value == SSHAuthenticationSubmission {
|
||||
nonisolated extension Dictionary where Key == Host.ID, Value == SSHAuthenticationSubmission {
|
||||
public func firstHostRequiringPassword(in route: [Host]) -> Host? {
|
||||
route.first { self[$0.id]?.requiresPasswordReentry == true }
|
||||
}
|
||||
|
||||
public func firstHostRequiringPassphrase(
|
||||
in route: [Host], key: (SSHKeyRef.ID) -> SSHKeyRef?
|
||||
) -> Host? {
|
||||
route.first { host in
|
||||
guard let submission = self[host.id], !submission.hasPassphrase,
|
||||
let keyID = submission.keyID
|
||||
else { return false }
|
||||
return key(keyID)?.requiresPassphraseEntry == true
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves plaintext answers into one connection attempt while leaving only
|
||||
/// non-secret selection metadata in the session's retry state.
|
||||
public mutating func takeAuthenticationSecrets() -> Self {
|
||||
let result = self
|
||||
for key in keys {
|
||||
self[key]?.clearSecrets()
|
||||
var result: Self = [:]
|
||||
for key in Array(keys) {
|
||||
guard var retained = self[key] else { continue }
|
||||
var attempt = retained
|
||||
attempt.prepareForConnectionAttempt()
|
||||
retained.clearSecrets()
|
||||
self[key] = retained
|
||||
result[key] = attempt
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,33 +1,64 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
/// Centralized clearing for short-lived buffers that temporarily contain secrets.
|
||||
public nonisolated enum SecretMemory {
|
||||
public static func clear(_ bytes: inout Data) {
|
||||
bytes.withUnsafeMutableBytes { raw in
|
||||
guard let base = raw.baseAddress, raw.count > 0 else { return }
|
||||
_ = memset_s(base, raw.count, 0, raw.count)
|
||||
}
|
||||
bytes.removeAll(keepingCapacity: false)
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-shot UTF-8 secret used only while authenticating a connection.
|
||||
///
|
||||
/// Copies of an `SSHSecret` share one backing object. Consuming or clearing any
|
||||
/// reference therefore invalidates every reference instead of creating another
|
||||
/// plaintext lifetime through Swift value copying. The backing bytes are
|
||||
/// overwritten with `memset_s` after use and again from `deinit` as a
|
||||
/// defensive fallback.
|
||||
/// reference therefore invalidates every reference. The plaintext lives in one
|
||||
/// explicitly owned, non-copy-on-write allocation that is overwritten before it
|
||||
/// is released.
|
||||
public nonisolated final class SSHSecret: @unchecked Sendable {
|
||||
public struct Consumed: Error, Sendable {
|
||||
public init() {}
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var storage: Data?
|
||||
private struct Storage {
|
||||
var pointer: UnsafeMutableRawPointer
|
||||
var count: Int
|
||||
|
||||
public init(_ value: String) {
|
||||
var bytes = Data(value.utf8)
|
||||
bytes.append(0)
|
||||
storage = bytes
|
||||
func clearAndDeallocate() {
|
||||
_ = memset_s(pointer, count + 1, 0, count + 1)
|
||||
pointer.deallocate()
|
||||
}
|
||||
}
|
||||
|
||||
/// Transfers a freshly read UTF-8 buffer into wipeable ownership without
|
||||
/// first materializing an immutable Swift `String`.
|
||||
private let lock = NSLock()
|
||||
private var storage: Storage?
|
||||
|
||||
public convenience init(_ value: String) {
|
||||
var bytes = Data(value.utf8)
|
||||
self.init(consumingUTF8: &bytes)
|
||||
}
|
||||
|
||||
/// 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 init(consumingUTF8 bytes: inout Data) {
|
||||
bytes.append(0)
|
||||
storage = bytes
|
||||
bytes = Data()
|
||||
let count = bytes.count
|
||||
let pointer = UnsafeMutableRawPointer.allocate(
|
||||
byteCount: count + 1,
|
||||
alignment: MemoryLayout<UInt8>.alignment
|
||||
)
|
||||
if count > 0 {
|
||||
bytes.withUnsafeBytes { raw in
|
||||
guard let source = raw.baseAddress else { return }
|
||||
pointer.copyMemory(from: source, byteCount: count)
|
||||
}
|
||||
}
|
||||
pointer.storeBytes(of: UInt8(0), toByteOffset: count, as: UInt8.self)
|
||||
SecretMemory.clear(&bytes)
|
||||
storage = Storage(pointer: pointer, count: count)
|
||||
}
|
||||
|
||||
deinit { clear() }
|
||||
@@ -37,56 +68,46 @@ public nonisolated final class SSHSecret: @unchecked Sendable {
|
||||
}
|
||||
|
||||
/// Exposes the UTF-8 bytes once, including a trailing NUL outside `count`.
|
||||
/// The memory is wiped before this method returns or throws.
|
||||
/// The allocation is wiped before this method returns or throws.
|
||||
public func withUnsafeCStringAndConsume<Result>(
|
||||
_ body: (UnsafePointer<CChar>, Int) throws -> Result
|
||||
) throws -> Result {
|
||||
var bytes = try take()
|
||||
defer { Self.wipe(&bytes) }
|
||||
return try bytes.withUnsafeBytes { raw in
|
||||
guard let base = raw.baseAddress?.assumingMemoryBound(to: CChar.self) else {
|
||||
throw Consumed()
|
||||
}
|
||||
return try body(base, max(0, raw.count - 1))
|
||||
}
|
||||
let storage = try take()
|
||||
defer { storage.clearAndDeallocate() }
|
||||
return try body(
|
||||
UnsafePointer(storage.pointer.assumingMemoryBound(to: CChar.self)),
|
||||
storage.count
|
||||
)
|
||||
}
|
||||
|
||||
/// Test and diagnostic support without extending the secret's lifetime.
|
||||
/// Production authentication should use `withUnsafeCStringAndConsume`.
|
||||
/// Test support without extending the secret's lifetime.
|
||||
public func matches(_ value: String) -> Bool {
|
||||
let expected = Array(value.utf8)
|
||||
return lock.withLock {
|
||||
guard let storage, storage.count == expected.count + 1 else { return false }
|
||||
return storage.dropLast().elementsEqual(expected)
|
||||
guard let storage, storage.count == expected.count else { return false }
|
||||
let bytes = UnsafeRawBufferPointer(start: storage.pointer, count: storage.count)
|
||||
return bytes.elementsEqual(expected)
|
||||
}
|
||||
}
|
||||
|
||||
public func clear() {
|
||||
guard
|
||||
var bytes = lock.withLock({ () -> Data? in
|
||||
let storage = lock.withLock({ () -> Storage? in
|
||||
let value = storage
|
||||
storage = nil
|
||||
self.storage = nil
|
||||
return value
|
||||
})
|
||||
else { return }
|
||||
Self.wipe(&bytes)
|
||||
storage.clearAndDeallocate()
|
||||
}
|
||||
|
||||
private func take() throws -> Data {
|
||||
private func take() throws -> Storage {
|
||||
try lock.withLock {
|
||||
guard let value = storage else { throw Consumed() }
|
||||
storage = nil
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private static func wipe(_ bytes: inout Data) {
|
||||
bytes.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) in
|
||||
guard let base = raw.baseAddress, raw.count > 0 else { return }
|
||||
_ = memset_s(base, raw.count, 0, raw.count)
|
||||
}
|
||||
bytes.removeAll(keepingCapacity: false)
|
||||
}
|
||||
}
|
||||
|
||||
public nonisolated func == (lhs: SSHSecret, rhs: String) -> Bool {
|
||||
|
||||
@@ -9,16 +9,21 @@ public nonisolated struct SSHPrivateKeySecret: Codable, Hashable, Sendable {
|
||||
|
||||
}
|
||||
|
||||
public nonisolated protocol SecretStoring: Sendable {
|
||||
func string(for account: String, storage: PasswordStorage) throws -> String?
|
||||
public nonisolated protocol SSHRuntimeSecretResolving: Sendable {
|
||||
func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret?
|
||||
func sshKeyCredential(
|
||||
for account: String, storage: KeyStorage
|
||||
) throws -> SSHResolvedKeyCredential?
|
||||
}
|
||||
|
||||
public nonisolated protocol SecretStoring: SSHRuntimeSecretResolving {
|
||||
func string(for account: String, storage: PasswordStorage) throws -> String?
|
||||
func containsString(for account: String, storage: PasswordStorage) throws -> Bool
|
||||
func setString(_ value: String?, for account: String, storage: PasswordStorage) throws
|
||||
func keyCredential(
|
||||
for account: String, storage: KeyStorage
|
||||
) throws -> StoredSSHKeyCredential?
|
||||
func sshKeyCredential(
|
||||
for account: String, storage: KeyStorage
|
||||
) throws -> SSHResolvedKeyCredential?
|
||||
func containsKeyCredential(for account: String, storage: KeyStorage) throws -> Bool
|
||||
func setKeyCredential(
|
||||
_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage
|
||||
) throws
|
||||
@@ -27,6 +32,14 @@ public nonisolated protocol SecretStoring: Sendable {
|
||||
}
|
||||
|
||||
nonisolated extension SecretStoring {
|
||||
public func containsString(for account: String, storage: PasswordStorage) throws -> Bool {
|
||||
try string(for: account, storage: storage) != nil
|
||||
}
|
||||
|
||||
public func containsKeyCredential(for account: String, storage: KeyStorage) throws -> Bool {
|
||||
try keyCredential(for: account, storage: storage) != nil
|
||||
}
|
||||
|
||||
public func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret? {
|
||||
try string(for: account, storage: storage).map(SSHSecret.init)
|
||||
}
|
||||
|
||||
@@ -248,13 +248,15 @@ public final class AgentHostResolver: Sendable {
|
||||
|
||||
}
|
||||
|
||||
var retainedSubmissions = submissions
|
||||
let attemptSubmissions = retainedSubmissions.takeAuthenticationSecrets()
|
||||
return InteractiveResolution(
|
||||
configuration: try store.sshConnectionConfiguration(
|
||||
for: destination, submissions: submissions,
|
||||
for: destination, submissions: attemptSubmissions,
|
||||
temporaryHostKeys: temporaryHostKeys,
|
||||
trustPolicy: .allowChallenge
|
||||
),
|
||||
submissions: submissions, temporaryHostKeys: temporaryHostKeys
|
||||
submissions: retainedSubmissions, temporaryHostKeys: temporaryHostKeys
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -327,9 +327,7 @@ public final class PortForwardingController {
|
||||
host: host, route: hosts.route(for: host), purpose: .portForwarding,
|
||||
username: context.submissions[host.id]?.username ?? hosts.identity(for: host)?.username)
|
||||
update(context, state: .preparing)
|
||||
if let passwordHost = context.route.first(where: { routeHost in
|
||||
context.submissions[routeHost.id]?.requiresPasswordReentry == true
|
||||
}) {
|
||||
if let passwordHost = context.submissions.firstHostRequiringPassword(in: context.route) {
|
||||
requestAuthentication(
|
||||
context,
|
||||
error: .routeHost(
|
||||
@@ -341,14 +339,9 @@ public final class PortForwardingController {
|
||||
context.attemptID = nil
|
||||
return
|
||||
}
|
||||
if let passphraseHost = context.route.first(where: { routeHost in
|
||||
guard let submission = context.submissions[routeHost.id],
|
||||
submission.passphrase == nil,
|
||||
let keyID = submission.keyID,
|
||||
let key = hosts.key(keyID)
|
||||
else { return false }
|
||||
return key.requiresPassphraseEntry
|
||||
}) {
|
||||
if let passphraseHost = context.submissions.firstHostRequiringPassphrase(
|
||||
in: context.route, key: hosts.key
|
||||
) {
|
||||
requestAuthentication(
|
||||
context,
|
||||
error: .routeHost(
|
||||
|
||||
@@ -111,8 +111,18 @@ public nonisolated final class RemoteConnectionCoordinator: Sendable {
|
||||
_ publicKey: String, named keyName: String,
|
||||
using configuration: SSHConnectionConfiguration
|
||||
) async throws {
|
||||
try await SSHPTYChannel.installAuthorizedKey(
|
||||
publicKey, named: keyName,
|
||||
try await installAuthorizedKeys(
|
||||
[SSHAuthorizedKeyInstall(publicKey: publicKey, name: keyName)],
|
||||
using: configuration
|
||||
)
|
||||
}
|
||||
|
||||
public func installAuthorizedKeys(
|
||||
_ keys: [SSHAuthorizedKeyInstall],
|
||||
using configuration: SSHConnectionConfiguration
|
||||
) async throws {
|
||||
try await SSHPTYChannel.installAuthorizedKeys(
|
||||
keys,
|
||||
using: SSHConnectionRequest(configuration: configuration, keyboardInteractive: keyboardInteractive),
|
||||
logger: logger, maxSessionsPolicy: maxSessionsPolicy
|
||||
)
|
||||
|
||||
@@ -32,8 +32,20 @@ public nonisolated enum SSHConnectionOpener {
|
||||
logger: Logger = ApplicationLogger.disabled("termind.ssh-key-install"),
|
||||
maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy()
|
||||
) async throws {
|
||||
try await SSHPTYChannelWorker.installAuthorizedKey(
|
||||
publicKey, named: keyName, using: request, logger: logger,
|
||||
try await installAuthorizedKeys(
|
||||
[SSHAuthorizedKeyInstall(publicKey: publicKey, name: keyName)],
|
||||
using: request, logger: logger,
|
||||
maxSessionsPolicy: maxSessionsPolicy
|
||||
)
|
||||
}
|
||||
|
||||
public static func installAuthorizedKeys(
|
||||
_ keys: [SSHAuthorizedKeyInstall], using request: SSHConnectionRequest,
|
||||
logger: Logger = ApplicationLogger.disabled("termind.ssh-key-install"),
|
||||
maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy()
|
||||
) async throws {
|
||||
try await SSHPTYChannelWorker.installAuthorizedKeys(
|
||||
keys, using: request, logger: logger,
|
||||
maxSessionsPolicy: maxSessionsPolicy)
|
||||
}
|
||||
|
||||
|
||||
@@ -594,8 +594,20 @@ public actor SSHPTYChannel {
|
||||
logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-key-install"),
|
||||
maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy()
|
||||
) async throws {
|
||||
try await SSHConnectionOpener.installAuthorizedKey(
|
||||
publicKey, named: keyName, using: request, logger: logger,
|
||||
try await installAuthorizedKeys(
|
||||
[SSHAuthorizedKeyInstall(publicKey: publicKey, name: keyName)],
|
||||
using: request, logger: logger,
|
||||
maxSessionsPolicy: maxSessionsPolicy
|
||||
)
|
||||
}
|
||||
|
||||
public static func installAuthorizedKeys(
|
||||
_ keys: [SSHAuthorizedKeyInstall], using request: SSHConnectionRequest,
|
||||
logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-key-install"),
|
||||
maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy()
|
||||
) async throws {
|
||||
try await SSHConnectionOpener.installAuthorizedKeys(
|
||||
keys, using: request, logger: logger,
|
||||
maxSessionsPolicy: maxSessionsPolicy)
|
||||
}
|
||||
|
||||
@@ -883,25 +895,51 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-key-install"),
|
||||
maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy()
|
||||
) async throws {
|
||||
let input = try authorizedKeyInstallInput(publicKey: publicKey, keyName: keyName)
|
||||
let session = SSHConnection(logger: logger, maxSessionsPolicy: maxSessionsPolicy)
|
||||
try await installAuthorizedKeys(
|
||||
[SSHAuthorizedKeyInstall(publicKey: publicKey, name: keyName)],
|
||||
using: request, logger: logger,
|
||||
maxSessionsPolicy: maxSessionsPolicy
|
||||
)
|
||||
}
|
||||
|
||||
public static func installAuthorizedKeys(
|
||||
_ keys: [SSHAuthorizedKeyInstall],
|
||||
using request: SSHConnectionRequest,
|
||||
logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-key-install"),
|
||||
maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy()
|
||||
) async throws {
|
||||
let connection = SSHConnection(logger: logger, maxSessionsPolicy: maxSessionsPolicy)
|
||||
try await installAuthorizedKeys(keys, using: request, connection: connection)
|
||||
}
|
||||
|
||||
static func installAuthorizedKeys(
|
||||
_ keys: [SSHAuthorizedKeyInstall],
|
||||
using request: SSHConnectionRequest,
|
||||
connection: any SSHConnectionProtocol
|
||||
) async throws {
|
||||
guard !keys.isEmpty else { return }
|
||||
let inputs = try keys.map {
|
||||
try authorizedKeyInstallInput(publicKey: $0.publicKey, keyName: $0.name)
|
||||
}
|
||||
do {
|
||||
try await session.connect(using: request)
|
||||
try await connection.connect(using: request)
|
||||
let command =
|
||||
#"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"; }"#
|
||||
let result = try await session.execute(
|
||||
command, workingDirectory: nil, stdin: input, timeout: 20,
|
||||
outputLimit: 64 * 1024, onOutput: nil)
|
||||
guard result.exitCode == 0 else {
|
||||
let detail = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
throw TermindSSHError.library(
|
||||
detail.isEmpty
|
||||
? "The remote authorized_keys update exited with status \(result.exitCode)."
|
||||
: "The remote authorized_keys update failed: \(detail)")
|
||||
for input in inputs {
|
||||
let result = try await connection.execute(
|
||||
command, workingDirectory: nil, stdin: input, timeout: 20,
|
||||
outputLimit: 64 * 1024, onOutput: nil)
|
||||
guard result.exitCode == 0 else {
|
||||
let detail = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
throw TermindSSHError.library(
|
||||
detail.isEmpty
|
||||
? "The remote authorized_keys update exited with status \(result.exitCode)."
|
||||
: "The remote authorized_keys update failed: \(detail)")
|
||||
}
|
||||
}
|
||||
await session.disconnect()
|
||||
await connection.disconnect()
|
||||
} catch {
|
||||
await session.disconnect()
|
||||
await connection.disconnect()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,11 +90,11 @@ public nonisolated enum SSHSocketConnector {
|
||||
var request = Data(
|
||||
"CONNECT \(authority) HTTP/1.1\r\nHost: \(authority)\r\nProxy-Connection: Keep-Alive\r\n".utf8
|
||||
)
|
||||
defer { wipe(&request) }
|
||||
defer { SecretMemory.clear(&request) }
|
||||
if let password = proxy.password, !proxy.username.isEmpty {
|
||||
var plaintext = Data(proxy.username.utf8)
|
||||
plaintext.append(UInt8(ascii: ":"))
|
||||
defer { wipe(&plaintext) }
|
||||
defer { SecretMemory.clear(&plaintext) }
|
||||
try password.withUnsafeCStringAndConsume { buffer, count in
|
||||
plaintext.append(
|
||||
buffer.withMemoryRebound(to: UInt8.self, capacity: count) {
|
||||
@@ -103,7 +103,7 @@ public nonisolated enum SSHSocketConnector {
|
||||
)
|
||||
}
|
||||
var token = plaintext.base64EncodedData()
|
||||
defer { wipe(&token) }
|
||||
defer { SecretMemory.clear(&token) }
|
||||
request.append(Data("Proxy-Authorization: Basic ".utf8))
|
||||
request.append(token)
|
||||
request.append(Data("\r\n".utf8))
|
||||
@@ -152,7 +152,7 @@ public nonisolated enum SSHSocketConnector {
|
||||
throw TermindSSHError.proxy("The SOCKS5 username or password is too long.")
|
||||
}
|
||||
var auth = Data([0x01, UInt8(username.count)])
|
||||
defer { wipe(&auth) }
|
||||
defer { SecretMemory.clear(&auth) }
|
||||
auth.append(username)
|
||||
auth.append(UInt8(count))
|
||||
auth.append(
|
||||
@@ -206,14 +206,6 @@ public nonisolated enum SSHSocketConnector {
|
||||
_ = try readExactly(socket, count: addressLength + 2)
|
||||
}
|
||||
|
||||
private static func wipe(_ data: inout Data) {
|
||||
data.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) in
|
||||
guard let base = raw.baseAddress, raw.count > 0 else { return }
|
||||
_ = memset_s(base, raw.count, 0, raw.count)
|
||||
}
|
||||
data.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
private static func openTCP(host: String, port: Int, timeout: TimeInterval) throws -> Int32 {
|
||||
var hints = addrinfo()
|
||||
hints.ai_family = AF_UNSPEC
|
||||
|
||||
@@ -51,6 +51,14 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
|
||||
return value
|
||||
}
|
||||
|
||||
public func containsString(for account: String, storage: PasswordStorage) throws -> Bool {
|
||||
try validateStringReference(account)
|
||||
return try contains(
|
||||
account, service: Self.passwordService,
|
||||
synchronizable: storage == .iCloud
|
||||
)
|
||||
}
|
||||
|
||||
public func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret? {
|
||||
try validateStringReference(account)
|
||||
guard
|
||||
@@ -66,7 +74,7 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
|
||||
try validateStringReference(account)
|
||||
var data = value?.data(using: .utf8)
|
||||
defer {
|
||||
if data != nil { Self.wipe(&data!) }
|
||||
if data != nil { SecretMemory.clear(&data!) }
|
||||
}
|
||||
try setData(
|
||||
data, for: account, service: Self.passwordService,
|
||||
@@ -96,6 +104,14 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public func containsKeyCredential(for account: String, storage: KeyStorage) throws -> Bool {
|
||||
try validate(account, prefix: "termind.key.")
|
||||
return try contains(
|
||||
account, service: service(for: storage),
|
||||
synchronizable: storage == .iCloud
|
||||
)
|
||||
}
|
||||
|
||||
public func sshKeyCredential(
|
||||
for account: String, storage: KeyStorage
|
||||
) throws -> SSHResolvedKeyCredential? {
|
||||
@@ -106,11 +122,10 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
|
||||
synchronizable: storage == .iCloud
|
||||
)
|
||||
else { return nil }
|
||||
defer { SecretMemory.clear(&data) }
|
||||
switch storage {
|
||||
case .thisDevice, .iCloud:
|
||||
defer { Self.wipe(&data) }
|
||||
var privateKey = try Self.decodePrivateKeyBytes(from: data)
|
||||
return .software(SSHSecret(consumingUTF8: &privateKey))
|
||||
return .software(try Self.decodePrivateKeySecret(from: &data))
|
||||
case .secureEnclave:
|
||||
guard !data.isEmpty else { throw KeychainSecretError.unexpectedValue }
|
||||
return .secureEnclave(SecureEnclaveKeyCredential(dataRepresentation: data))
|
||||
@@ -133,7 +148,7 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
|
||||
throw KeychainSecretError.unexpectedValue
|
||||
}
|
||||
defer {
|
||||
if data != nil { Self.wipe(&data!) }
|
||||
if data != nil { SecretMemory.clear(&data!) }
|
||||
}
|
||||
try setData(
|
||||
data, for: account, service: service(for: storage),
|
||||
@@ -144,45 +159,51 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
private static func wipe(_ data: inout Data) {
|
||||
data.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) in
|
||||
guard let base = raw.baseAddress, raw.count > 0 else { return }
|
||||
_ = memset_s(base, raw.count, 0, raw.count)
|
||||
}
|
||||
data.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
/// Decodes the synthesized `SSHPrivateKeySecret` JSON directly into a
|
||||
/// mutable byte buffer so connection setup never materializes another
|
||||
/// immutable Swift `String` containing the private key.
|
||||
private static func decodePrivateKeyBytes(from data: Data) throws -> Data {
|
||||
let key = Data("\"privateKey\"".utf8)
|
||||
guard let keyRange = data.range(of: key) else {
|
||||
throw KeychainSecretError.unexpectedValue
|
||||
}
|
||||
var index = keyRange.upperBound
|
||||
/// 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.
|
||||
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]) {
|
||||
index = data.index(after: index)
|
||||
}
|
||||
}
|
||||
skipWhitespace()
|
||||
guard index < data.endIndex, data[index] == 0x3A else {
|
||||
throw KeychainSecretError.unexpectedValue
|
||||
func consume(_ expected: UInt8) throws {
|
||||
guard index < data.endIndex, data[index] == expected else {
|
||||
throw KeychainSecretError.unexpectedValue
|
||||
}
|
||||
index = data.index(after: index)
|
||||
}
|
||||
index = data.index(after: index)
|
||||
|
||||
skipWhitespace()
|
||||
guard index < data.endIndex, data[index] == 0x22 else {
|
||||
throw KeychainSecretError.unexpectedValue
|
||||
try consume(0x7B)
|
||||
skipWhitespace()
|
||||
for expected in Array("\"privateKey\"".utf8) {
|
||||
try consume(expected)
|
||||
}
|
||||
index = data.index(after: index)
|
||||
skipWhitespace()
|
||||
try consume(0x3A)
|
||||
skipWhitespace()
|
||||
try consume(0x22)
|
||||
|
||||
var result = Data()
|
||||
defer { SecretMemory.clear(&result) }
|
||||
while index < data.endIndex {
|
||||
let byte = data[index]
|
||||
index = data.index(after: index)
|
||||
if byte == 0x22 { return result }
|
||||
if byte == 0x22 {
|
||||
skipWhitespace()
|
||||
try consume(0x7D)
|
||||
skipWhitespace()
|
||||
guard index == data.endIndex else {
|
||||
throw KeychainSecretError.unexpectedValue
|
||||
}
|
||||
return SSHSecret(consumingUTF8: &result)
|
||||
}
|
||||
guard byte == 0x5C else {
|
||||
guard byte >= 0x20 else { throw KeychainSecretError.unexpectedValue }
|
||||
result.append(byte)
|
||||
continue
|
||||
}
|
||||
@@ -260,6 +281,18 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
|
||||
return data
|
||||
}
|
||||
|
||||
private func contains(_ account: String, service: String, synchronizable: Bool) throws -> Bool {
|
||||
guard !account.isEmpty else { return false }
|
||||
var query = baseQuery(account, service: service, synchronizable: synchronizable)
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
query[kSecReturnAttributes as String] = true
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return false }
|
||||
guard status == errSecSuccess else { throw KeychainSecretError.status(status) }
|
||||
return true
|
||||
}
|
||||
|
||||
private func setData(
|
||||
_ value: Data?, for account: String, service: String,
|
||||
synchronizable: Bool, accessible: CFString
|
||||
|
||||
@@ -94,10 +94,13 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r
|
||||
socket/libssh2 handshake-to-auth attempt, never a discovery connection. Generations reject retired callbacks,
|
||||
credential results and timers.
|
||||
- Every resolved Password, software Private Key, Key Passphrase and proxy Password is one-shot per physical connection
|
||||
attempt. Runtime copies share a wipeable byte buffer; the transport clears it after the authentication call and every
|
||||
success, failure and cancellation boundary clears it again. Retry/session/Agent/Metrics/transfer state retains only
|
||||
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.
|
||||
attempt. Runtime copies share one explicitly owned, non-copy-on-write allocation; source `Data`, proxy frames and the
|
||||
allocation are overwritten before release. The transport clears it after the authentication call and every success,
|
||||
failure and cancellation boundary clears it again. UI submissions move plaintext into that one-shot owner before
|
||||
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; the legacy software-Key JSON envelope is parsed strictly and fails closed.
|
||||
- Desktop SSH/SFTP submission uses `RemoteSessionHostStore`: explicit password wins; custom input never borrows an
|
||||
unselected Identity password. Only explicit input changes selected credentials; custom input reuses the Host Identity.
|
||||
Identity/secret writes precede separate Host association. Keep temporary credentials, validation/passphrase ordering,
|
||||
@@ -120,6 +123,8 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r
|
||||
- Username-only Identities try `none`, then advertised methods. Device-local `lastUsedAt` tracks successful saved-secret
|
||||
reads, not authentication. Batch route reads; missing/availability/edit reads do not count. First access per Identity/
|
||||
process writes directly without Vault journal/refresh; sync strips this field and preserves local values on apply.
|
||||
- Exporting several authorized Keys resolves credentials immediately before work, authenticates once per Host and opens
|
||||
one exec Channel per Key on that connection. Local validation completes before the remote account is changed.
|
||||
- Welcome parses bounded SSH grammar without shell/OpenSSH config execution. Exact aliases supply metadata; overrides
|
||||
create session route snapshots, never replaced with saved endpoints. Routes cap at eight Hosts and reject cycles.
|
||||
Connect saves nothing. Explicit Save copies changed records/prerequisites in one Vault transaction, rolling back new
|
||||
|
||||
@@ -239,3 +239,21 @@ never justifies extraction. Keep schemas, native lists/terminal teardown and loc
|
||||
`"channels_only"` raw value, so synced settings need no migration.
|
||||
- **Verification:** TFM and generic iOS Simulator builds passed. Sixty-six focused connection/Channel/SFTP tests passed;
|
||||
changed-file strict formatting and Chinese localization checks passed.
|
||||
|
||||
### 2026-09-20 — one-shot SSH secret ownership and connection attempts (complete)
|
||||
|
||||
- **Evidence:** runtime credentials were represented by copy-on-write `Data` after leaving Keychain, so appending a NUL
|
||||
could abandon an uncleared allocation. Temporary form answers were copied as `String` values into attempt dictionaries;
|
||||
five feature owners repeated password/passphrase reentry routing. Authorized-Key export resolved every selected Host's
|
||||
credentials eagerly and reused one one-shot configuration across several physical connections.
|
||||
- **Action:** give `SSHSecret` one explicitly owned allocation; move submitted plaintext into that owner before an attempt;
|
||||
centralize route reentry selection; narrow connection-time secret resolution; query Keychain existence without reading
|
||||
values; fail closed on malformed legacy private-Key envelopes; authenticate once per Host for multi-Key export.
|
||||
- **Result:** source `Data`, proxy frames and one-shot allocations are cleared through one `SecretMemory` boundary. Retry
|
||||
state retains only selection metadata and reentry flags, while attempt copies share invalidating secret owners. The
|
||||
session host port no longer exposes its legacy plaintext credential reads. Export preparations contain references only,
|
||||
resolve immediately before work and open one connection with sequential exec Channels for all selected Keys. This is a
|
||||
safety/ownership refactor; no production-line reduction is claimed.
|
||||
- **Verification:** macOS and generic iOS Simulator builds passed. Fifty-three focused authentication, Agent and
|
||||
authorized-Key tests passed; all 381 Framework tests passed. The default macOS run finished with 1,792 passed, 64
|
||||
opt-in skips and no failures. Changed-file strict formatting, diff checks and Chinese localization passed.
|
||||
@@ -559,9 +559,7 @@ final class MobileWorkspace: Identifiable {
|
||||
purpose: descriptor.kind == .ssh ? .sshSession : .sftpChannel,
|
||||
username: hosts.identity(for: host)?.username,
|
||||
mode: reconnecting ? .reconnection : .connection)
|
||||
if let passwordHost = route.first(where: { routeHost in
|
||||
submissions[routeHost.id]?.requiresPasswordReentry == true
|
||||
}) {
|
||||
if let passwordHost = submissions.firstHostRequiringPassword(in: route) {
|
||||
authenticationRequired = true
|
||||
authenticationMode = .localIdentity
|
||||
authenticationHostID = passwordHost.id
|
||||
@@ -573,14 +571,9 @@ final class MobileWorkspace: Identifiable {
|
||||
busy = false
|
||||
return
|
||||
}
|
||||
if let passphraseHost = route.first(where: { routeHost in
|
||||
guard let submission = submissions[routeHost.id],
|
||||
submission.passphrase == nil,
|
||||
let keyID = submission.keyID,
|
||||
let key = hosts.key(keyID)
|
||||
else { return false }
|
||||
return key.requiresPassphraseEntry
|
||||
}) {
|
||||
if let passphraseHost = submissions.firstHostRequiringPassphrase(
|
||||
in: route, key: hosts.key
|
||||
) {
|
||||
authenticationRequired = true
|
||||
authenticationMode = .localIdentity
|
||||
authenticationHostID = passphraseHost.id
|
||||
|
||||
@@ -85,17 +85,9 @@ extension TransferLibraryModel: RemoteSessionHostStore {
|
||||
func sshCredentials(for identity: Identity) -> SSHCredentialResolution {
|
||||
library.sshCredentials(for: identity)
|
||||
}
|
||||
func passwordCredential(for identity: Identity) throws -> String? {
|
||||
try library.passwordCredential(for: identity)
|
||||
}
|
||||
|
||||
func passwordSecret(for identity: Identity) throws -> SSHSecret? {
|
||||
try library.passwordSecret(for: identity)
|
||||
}
|
||||
func keyCredential(for key: SSHKeyRef) throws -> StoredSSHKeyCredential? {
|
||||
try library.keyCredential(for: key)
|
||||
}
|
||||
|
||||
func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential? {
|
||||
try library.keySecretCredential(for: key)
|
||||
}
|
||||
|
||||
@@ -1389,15 +1389,15 @@ struct ExportKeyToHostsSheet: View {
|
||||
}
|
||||
guard !targets.isEmpty else { return }
|
||||
|
||||
var configurations: [(Host.ID, SSHConnectionConfiguration)] = []
|
||||
var preparations: [(Host.ID, SSHConnectionPreparation)] = []
|
||||
for host in targets {
|
||||
if let reason = app.credentials.keyExportUnavailableReason(for: host) {
|
||||
states[host.id] = .failed(reason)
|
||||
continue
|
||||
}
|
||||
do {
|
||||
configurations.append(
|
||||
(host.id, try app.credentials.sshConnectionConfiguration(for: host))
|
||||
preparations.append(
|
||||
(host.id, try app.credentials.prepareSSHConnection(for: host))
|
||||
)
|
||||
states[host.id] = .running
|
||||
} catch {
|
||||
@@ -1405,18 +1405,19 @@ struct ExportKeyToHostsSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
let exportKeys = keys.map { (name: $0.label, publicKey: $0.publicKey) }
|
||||
let exportKeys = keys.map {
|
||||
SSHAuthorizedKeyInstall(publicKey: $0.publicKey, name: $0.label)
|
||||
}
|
||||
let connectionCoordinator = app.connections
|
||||
Task {
|
||||
await withTaskGroup(of: (Host.ID, String?).self) { group in
|
||||
for (id, configuration) in configurations {
|
||||
for (id, preparation) in preparations {
|
||||
group.addTask {
|
||||
do {
|
||||
for key in exportKeys {
|
||||
try await connectionCoordinator.installAuthorizedKey(
|
||||
key.publicKey, named: key.name, using: configuration
|
||||
)
|
||||
}
|
||||
let configuration = try await preparation.resolve()
|
||||
try await connectionCoordinator.installAuthorizedKeys(
|
||||
exportKeys, using: configuration
|
||||
)
|
||||
return (id, nil)
|
||||
} catch {
|
||||
return (id, error.localizedDescription)
|
||||
|
||||
@@ -314,23 +314,16 @@ final class SFTPChannelModel: Identifiable {
|
||||
username: stagedAuthentication?.username ?? store.identity(for: host)?.username,
|
||||
mode: reconnecting ? .reconnection : .connection
|
||||
)
|
||||
if let passwordHost = route.first(where: { routeHost in
|
||||
suppliedAuthentications[routeHost.id]?.requiresPasswordReentry == true
|
||||
}) {
|
||||
if let passwordHost = suppliedAuthentications.firstHostRequiringPassword(in: route) {
|
||||
requestAuthentication(
|
||||
host: passwordHost, store: store,
|
||||
requiresPassphrase: false
|
||||
)
|
||||
return
|
||||
}
|
||||
if let passphraseHost = route.first(where: { routeHost in
|
||||
guard let submission = suppliedAuthentications[routeHost.id],
|
||||
submission.passphrase == nil,
|
||||
let keyID = submission.keyID,
|
||||
let key = store.key(keyID)
|
||||
else { return false }
|
||||
return key.requiresPassphraseEntry
|
||||
}) {
|
||||
if let passphraseHost = suppliedAuthentications.firstHostRequiringPassphrase(
|
||||
in: route, key: store.key
|
||||
) {
|
||||
requestAuthentication(
|
||||
host: passphraseHost, store: store,
|
||||
requiresPassphrase: true, preserveSubmission: true
|
||||
@@ -366,12 +359,12 @@ final class SFTPChannelModel: Identifiable {
|
||||
credentials.append(
|
||||
stored.sshCredential(
|
||||
publicKey: key.publicKey, certificate: key.certificate,
|
||||
passphraseOverride: suppliedAuthentication.passphrase,
|
||||
passphraseOverride: suppliedAuthentication.passphraseForConnection(),
|
||||
requiresPassphrase: key.requiresPassphraseEntry,
|
||||
requiresUserPresence: key.requiresUserPresence
|
||||
))
|
||||
}
|
||||
if let password = suppliedAuthentication.password {
|
||||
if let password = suppliedAuthentication.passwordForConnection() {
|
||||
credentials.append(.password(password))
|
||||
} else if let identityID = suppliedAuthentication.identityID,
|
||||
let selectedIdentity = store.identity(identityID), selectedIdentity.usesPassword
|
||||
@@ -398,9 +391,9 @@ final class SFTPChannelModel: Identifiable {
|
||||
? suppliedAuthentication.keyID : nil,
|
||||
username: suppliedAuthentication.username,
|
||||
permitsNone: suppliedAuthentication.keyID == nil
|
||||
&& suppliedAuthentication.password == nil,
|
||||
hasEphemeralSecret: suppliedAuthentication.password != nil
|
||||
|| suppliedAuthentication.passphrase != nil
|
||||
&& !suppliedAuthentication.hasPassword,
|
||||
hasEphemeralSecret: suppliedAuthentication.hasPassword
|
||||
|| suppliedAuthentication.hasPassphrase
|
||||
)
|
||||
)
|
||||
} else {
|
||||
@@ -489,10 +482,10 @@ final class SFTPChannelModel: Identifiable {
|
||||
let submitted = self.suppliedAuthentications[authenticationHost.id]
|
||||
let retryNeedsPassphrase =
|
||||
submitted?.requiresPasswordReentry != true
|
||||
&& submitted?.passphrase == nil
|
||||
&& submitted?.hasPassphrase != true
|
||||
&& (submitted?.keyID ?? store.identity(for: authenticationHost)?.keyID)
|
||||
.flatMap { store.key($0) }?.requiresPassphraseEntry == true
|
||||
let returnsToIdentity = submitted?.passphrase != nil
|
||||
let returnsToIdentity = submitted?.requiresPassphraseReentry == true
|
||||
self.requestAuthentication(
|
||||
host: authenticationHost, store: store,
|
||||
failure: error.localizedDescription,
|
||||
|
||||
@@ -489,23 +489,18 @@ final class SSHSessionModel: Identifiable {
|
||||
host: host, route: route, purpose: .sshSession, username: username,
|
||||
mode: reconnecting ? .reconnection : .connection
|
||||
)
|
||||
if let passwordHost = route.first(where: { routeHost in
|
||||
suppliedAuthentications[routeHost.id]?.requiresPasswordReentry == true
|
||||
}), let submission = suppliedAuthentications[passwordHost.id] {
|
||||
if let passwordHost = suppliedAuthentications.firstHostRequiringPassword(in: route),
|
||||
let submission = suppliedAuthentications[passwordHost.id]
|
||||
{
|
||||
requestAuthentication(
|
||||
for: passwordHost, username: submission.username,
|
||||
selectedKeyID: submission.keyID, requiresPassphrase: false
|
||||
)
|
||||
return
|
||||
}
|
||||
if let passphraseHost = route.first(where: { routeHost in
|
||||
guard let submission = suppliedAuthentications[routeHost.id],
|
||||
submission.passphrase == nil,
|
||||
let keyID = submission.keyID,
|
||||
let key = store.key(keyID)
|
||||
else { return false }
|
||||
return key.requiresPassphraseEntry
|
||||
}), let submission = suppliedAuthentications[passphraseHost.id] {
|
||||
if let passphraseHost = suppliedAuthentications.firstHostRequiringPassphrase(
|
||||
in: route, key: store.key
|
||||
), let submission = suppliedAuthentications[passphraseHost.id] {
|
||||
requestAuthentication(
|
||||
for: passphraseHost, username: submission.username,
|
||||
selectedKeyID: submission.keyID, requiresPassphrase: true,
|
||||
@@ -534,7 +529,7 @@ final class SSHSessionModel: Identifiable {
|
||||
if let suppliedAuthentication {
|
||||
hasConfiguredCredential =
|
||||
suppliedAuthentication.keyID != nil
|
||||
|| suppliedAuthentication.password != nil
|
||||
|| suppliedAuthentication.hasPassword
|
||||
|| suppliedAuthentication.identityID.flatMap(store.identity)?.hasSecret == true
|
||||
if let keyID = suppliedAuthentication.keyID {
|
||||
if let key = store.key(keyID) {
|
||||
@@ -545,7 +540,7 @@ final class SSHSessionModel: Identifiable {
|
||||
credentials.append(
|
||||
credential.sshCredential(
|
||||
publicKey: key.publicKey, certificate: key.certificate,
|
||||
passphraseOverride: suppliedAuthentication.passphrase,
|
||||
passphraseOverride: suppliedAuthentication.passphraseForConnection(),
|
||||
requiresPassphrase: key.requiresPassphraseEntry,
|
||||
requiresUserPresence: key.requiresUserPresence
|
||||
)
|
||||
@@ -564,7 +559,7 @@ final class SSHSessionModel: Identifiable {
|
||||
)
|
||||
}
|
||||
}
|
||||
if let password = suppliedAuthentication.password {
|
||||
if let password = suppliedAuthentication.passwordForConnection() {
|
||||
credentials.append(.password(password))
|
||||
} else if let identityID = suppliedAuthentication.identityID,
|
||||
let selectedIdentity = store.identity(identityID), selectedIdentity.usesPassword
|
||||
@@ -634,7 +629,7 @@ final class SSHSessionModel: Identifiable {
|
||||
let identity = store.identity(for: jump)
|
||||
let submitted = suppliedAuthentications[jump.id]
|
||||
let retryNeedsPassphrase =
|
||||
submitted?.passphrase == nil
|
||||
submitted?.hasPassphrase != true
|
||||
&& (submitted?.keyID ?? identity?.keyID)
|
||||
.flatMap { store.key($0) }?.requiresPassphraseEntry == true
|
||||
connectionAttemptID = nil
|
||||
@@ -665,16 +660,16 @@ final class SSHSessionModel: Identifiable {
|
||||
identityID: identityID,
|
||||
username: username,
|
||||
permitsNone: identity?.hasSecret == false,
|
||||
hasEphemeralSecret: suppliedAuthentication?.password != nil
|
||||
|| suppliedAuthentication?.passphrase != nil
|
||||
hasEphemeralSecret: suppliedAuthentication?.hasPassword == true
|
||||
|| suppliedAuthentication?.hasPassphrase == true
|
||||
)
|
||||
} else {
|
||||
authenticationReference = SSHAuthenticationReference(
|
||||
fallbackKeyID: selectedKeyID,
|
||||
username: username,
|
||||
permitsNone: selectedKeyID == nil && suppliedAuthentication?.password == nil,
|
||||
hasEphemeralSecret: suppliedAuthentication?.password != nil
|
||||
|| suppliedAuthentication?.passphrase != nil
|
||||
permitsNone: selectedKeyID == nil && suppliedAuthentication?.hasPassword != true,
|
||||
hasEphemeralSecret: suppliedAuthentication?.hasPassword == true
|
||||
|| suppliedAuthentication?.hasPassphrase == true
|
||||
)
|
||||
}
|
||||
connectionTask = Task { [weak self, weak store] in
|
||||
@@ -714,10 +709,10 @@ final class SSHSessionModel: Identifiable {
|
||||
?? authenticationIdentity?.keyID ?? (authenticationHost.id == host.id ? selectedKeyID : nil)
|
||||
let retryNeedsPassphrase =
|
||||
submitted?.requiresPasswordReentry != true
|
||||
&& submitted?.passphrase == nil
|
||||
&& submitted?.hasPassphrase != true
|
||||
&& authenticationKeyID.flatMap { store.key($0) }?
|
||||
.requiresPassphraseEntry == true
|
||||
let returnsToIdentity = submitted?.passphrase != nil
|
||||
let returnsToIdentity = submitted?.requiresPassphraseReentry == true
|
||||
requestAuthentication(
|
||||
for: authenticationHost,
|
||||
username: submitted?.username ?? authenticationIdentity?.username
|
||||
|
||||
@@ -4,10 +4,10 @@ import TermindDomain
|
||||
import TermindInfrastructure
|
||||
import TermindPersistence
|
||||
import TermindPresentation
|
||||
import TermindRemoteInfrastructure
|
||||
import Testing
|
||||
|
||||
@testable import Termind
|
||||
@testable import TermindRemoteInfrastructure
|
||||
|
||||
struct AuthorizedKeyExportTests {
|
||||
@Test
|
||||
@@ -35,4 +35,104 @@ struct AuthorizedKeyExportTests {
|
||||
== "# Production Server Key by Termind\nssh-ed25519 AAAAC3Nza\n"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func legacyPrivateKeyEnvelopeRequiresACompleteJSONObject() throws {
|
||||
var encoded = try JSONEncoder().encode(
|
||||
SSHPrivateKeySecret(privateKey: "-----BEGIN TEST-----\nsecret\n-----END TEST-----")
|
||||
)
|
||||
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.decodePrivateKeySecret(
|
||||
from: &data
|
||||
)
|
||||
}
|
||||
#expect(throws: KeychainSecretError.self) {
|
||||
var data = Data(#"{"privateKey":"secret"} trailing"#.utf8)
|
||||
_ = try KeychainSecretStore.decodePrivateKeySecret(
|
||||
from: &data
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func multipleKeysReuseOneAuthenticatedConnection() async throws {
|
||||
let password = SSHSecret("one-use")
|
||||
let request = SSHConnectionRequest(
|
||||
configuration: SSHConnectionConfiguration(
|
||||
hostID: UUID(), host: "server.invalid", port: 22,
|
||||
username: "deploy", credentials: [.password(password)],
|
||||
expectedHostKeys: []
|
||||
)
|
||||
)
|
||||
let connection = AuthorizedKeyConnection()
|
||||
|
||||
try await SSHPTYChannelWorker.installAuthorizedKeys(
|
||||
[
|
||||
SSHAuthorizedKeyInstall(publicKey: "ssh-ed25519 first", name: "First"),
|
||||
SSHAuthorizedKeyInstall(publicKey: "ssh-ed25519 second", name: "Second"),
|
||||
],
|
||||
using: request, connection: connection
|
||||
)
|
||||
|
||||
let snapshot = await connection.snapshot()
|
||||
#expect(snapshot.connects == 1)
|
||||
#expect(snapshot.executions == 2)
|
||||
#expect(snapshot.disconnects == 1)
|
||||
#expect(
|
||||
snapshot.inputs == [
|
||||
"# First Key by Termind\nssh-ed25519 first\n",
|
||||
"# Second Key by Termind\nssh-ed25519 second\n",
|
||||
])
|
||||
#expect(password.isConsumed)
|
||||
}
|
||||
}
|
||||
|
||||
private actor AuthorizedKeyConnection: SSHConnectionProtocol {
|
||||
struct Snapshot: Sendable {
|
||||
var connects: Int
|
||||
var executions: Int
|
||||
var disconnects: Int
|
||||
var inputs: [String]
|
||||
}
|
||||
|
||||
private var connects = 0
|
||||
private var executions = 0
|
||||
private var disconnects = 0
|
||||
private var inputs: [String] = []
|
||||
|
||||
func connect(using configuration: SSHConnectionConfiguration) async throws {
|
||||
connects += 1
|
||||
}
|
||||
|
||||
func execute(
|
||||
_ command: String, workingDirectory: String?, stdin: Data?,
|
||||
timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler?
|
||||
) async throws -> SSHExecResult {
|
||||
executions += 1
|
||||
inputs.append(stdin.map { String(decoding: $0, as: UTF8.self) } ?? "")
|
||||
return SSHExecResult(
|
||||
stdout: "", stderr: "", exitCode: 0,
|
||||
stdoutTruncated: false, stderrTruncated: false
|
||||
)
|
||||
}
|
||||
|
||||
func openSFTPChannel() async throws -> any SFTPChannelProtocol {
|
||||
throw TermindSSHError.disconnected
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
disconnects += 1
|
||||
}
|
||||
|
||||
func snapshot() -> Snapshot {
|
||||
Snapshot(
|
||||
connects: connects, executions: executions,
|
||||
disconnects: disconnects, inputs: inputs
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user