fix(ssh): restore saved passphrases and authentication saving
This commit is contained in:
1 parent
0a9d5100e6
commit
b58c8beb5e
32 files changed
+1261
-241
No files matched your search
@@ -180,7 +180,7 @@ struct SSHConnectionPreparationTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func protectedKeyIsNotReadBeforePassphraseIsRequested() async {
|
||||
func protectedKeyWithoutSavedPassphraseRequestsEntry() async {
|
||||
let key = SSHKeyRef(
|
||||
label: "Protected", publicKey: "ssh-ed25519 test",
|
||||
credentialRef: "key.protected", hasPassphrase: true
|
||||
|
||||
@@ -149,7 +149,7 @@ struct SSHInteractionPolicyTests {
|
||||
#expect(password == "current-password")
|
||||
}
|
||||
|
||||
@Test func additionalResolutionSkipsPassphraseKeyAndUsesIdentityPassword() throws {
|
||||
@Test func additionalResolutionChecksForSavedPassphraseAndUsesIdentityPassword() throws {
|
||||
let secrets = CountingSecretStore()
|
||||
let protectedKey = SSHKeyRef(
|
||||
label: "Encrypted", publicKey: "ssh-ed25519 public",
|
||||
@@ -174,9 +174,9 @@ struct SSHInteractionPolicyTests {
|
||||
let request = try store.additionalConnectionRequest(
|
||||
from: SSHAdditionalConnectionSnapshot(target: node)
|
||||
)
|
||||
#expect(secrets.keyReads == 0)
|
||||
#expect(secrets.keyReads == 1)
|
||||
guard case .interactionRequired = request.configuration.credentials[0] else {
|
||||
Issue.record("Passphrase key must remain unread")
|
||||
Issue.record("A key without a saved passphrase must require interaction")
|
||||
return
|
||||
}
|
||||
guard case .password(let password) = request.configuration.credentials[1] else {
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import Synchronization
|
||||
import TermindDomain
|
||||
import Testing
|
||||
|
||||
@testable import TermindApplication
|
||||
|
||||
@Suite @MainActor
|
||||
struct SavedPassphraseTests {
|
||||
@Test func keychainCardObservesSavingAndClearingThePassphrase() throws {
|
||||
let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true)
|
||||
let store = HostLibraryModel()
|
||||
try store.upsert(key, credential: .software(.init(privateKey: "encrypted")))
|
||||
let credentials = CredentialLibraryModel(library: store)
|
||||
let changed = Mutex(false)
|
||||
withObservationTracking {
|
||||
#expect(!credentials.hasSavedPassphrase(for: key))
|
||||
} onChange: {
|
||||
changed.withLock { $0 = true }
|
||||
}
|
||||
try store.saveKeyPassphrase("saved", for: key.id)
|
||||
#expect(changed.withLock { $0 })
|
||||
#expect(credentials.hasSavedPassphrase(for: key))
|
||||
changed.withLock { $0 = false }
|
||||
withObservationTracking {
|
||||
#expect(credentials.hasSavedPassphrase(for: key))
|
||||
} onChange: {
|
||||
changed.withLock { $0 = true }
|
||||
}
|
||||
try store.upsert(key, credential: .software(.init(privateKey: "encrypted")))
|
||||
#expect(changed.withLock { $0 })
|
||||
#expect(!credentials.hasSavedPassphrase(for: key))
|
||||
}
|
||||
|
||||
@Test(arguments: [false, true], [false, true])
|
||||
func passphraseSaveIsExplicitAndPreservesStagedIdentity(save: Bool, allowsSaving: Bool) throws {
|
||||
let secrets = InMemorySecretStore()
|
||||
let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true)
|
||||
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
|
||||
let host = Host(name: "Host", host: "example.invalid")
|
||||
let store = HostLibraryModel(hosts: [host], keys: [key], identities: [identity], secrets: secrets)
|
||||
try secrets.setKeyCredential(
|
||||
.software(.init(privateKey: "encrypted")), for: key.credentialRef, storage: key.storage)
|
||||
let prompt = SSHAuthenticationPrompt(
|
||||
host: host, selectedIdentityID: identity.id, username: "deploy", selectedKeyID: key.id,
|
||||
requiresPassphrase: true, allowsSaving: allowsSaving)
|
||||
let staged = SSHAuthenticationSubmission(
|
||||
username: "deploy", password: "fallback", keyID: key.id, identityID: identity.id)
|
||||
let result = try store.passphraseSubmission(for: prompt, existing: staged, passphrase: "saved", save: save)
|
||||
#expect(result.password == "fallback")
|
||||
#expect(result.identityID == identity.id)
|
||||
#expect(result.keyID == key.id)
|
||||
#expect(result.passphrase == "saved")
|
||||
#expect(store.host(host.id) == host)
|
||||
#expect(store.identities == [identity])
|
||||
#expect(store.requiresPassphraseEntry(for: key) == !(save && allowsSaving))
|
||||
guard case .software(let credential)? = try secrets.keyCredential(for: key.credentialRef, storage: key.storage)
|
||||
else {
|
||||
Issue.record("Missing key")
|
||||
return
|
||||
}
|
||||
#expect(credential.passphrase == (save && allowsSaving ? "saved" : nil))
|
||||
}
|
||||
|
||||
@Test func savingWithoutAStagedSubmissionDoesNotSaveTheHostIdentity() throws {
|
||||
let secrets = InMemorySecretStore()
|
||||
let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true)
|
||||
let store = HostLibraryModel(keys: [key], secrets: secrets)
|
||||
try secrets.setKeyCredential(
|
||||
.software(.init(privateKey: "encrypted")), for: key.credentialRef, storage: key.storage)
|
||||
let host = Host(name: "Host", host: "example.invalid")
|
||||
let prompt = SSHAuthenticationPrompt(
|
||||
host: host, selectedIdentityID: nil, username: "deploy", selectedKeyID: key.id, requiresPassphrase: true)
|
||||
let result = try store.passphraseSubmission(for: prompt, existing: nil, passphrase: "saved", save: true)
|
||||
#expect(result.username == "deploy")
|
||||
#expect(store.hosts.isEmpty && store.identities.isEmpty)
|
||||
#expect(!store.requiresPassphraseEntry(for: key))
|
||||
}
|
||||
|
||||
@Test func missingKeySaveFailsWithoutMutatingTheSubmission() {
|
||||
let store = HostLibraryModel()
|
||||
let keyID = UUID()
|
||||
let staged = SSHAuthenticationSubmission(username: "deploy", password: "fallback", keyID: keyID)
|
||||
let prompt = SSHAuthenticationPrompt(
|
||||
host: Host(name: "Host", host: "example.invalid"), selectedIdentityID: nil, username: "deploy",
|
||||
selectedKeyID: keyID, requiresPassphrase: true)
|
||||
#expect(throws: TermindSSHError.self) {
|
||||
_ = try store.passphraseSubmission(for: prompt, existing: staged, passphrase: "saved", save: true)
|
||||
}
|
||||
#expect(staged.passphrase == nil && staged.password == "fallback")
|
||||
}
|
||||
|
||||
@Test func savedPassphrasesAllowSilentTargetAndJumpConnectionsAndAreReread() throws {
|
||||
let secrets = InMemorySecretStore()
|
||||
let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true)
|
||||
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
|
||||
let store = HostLibraryModel(keys: [key], identities: [identity], secrets: secrets)
|
||||
try secrets.setKeyCredential(
|
||||
.software(.init(privateKey: "encrypted", passphrase: "saved")), for: key.credentialRef, storage: key.storage
|
||||
)
|
||||
func node(_ name: String) -> SSHAdditionalConnectionSnapshot.Node {
|
||||
.init(
|
||||
id: UUID(), name: name, host: "example.invalid", port: 22,
|
||||
authentication: .init(identityID: identity.id, username: "deploy"), expectedHostKeys: [], proxy: nil)
|
||||
}
|
||||
let snapshot = SSHAdditionalConnectionSnapshot(target: node("Target"), jumps: [node("Jump")])
|
||||
let first = try store.additionalConnectionRequest(from: snapshot)
|
||||
defer { first.configuration.clearSecrets() }
|
||||
for credential in first.configuration.credentials + first.configuration.jumps.flatMap(\.credentials) {
|
||||
guard case .privateKey(_, _, let passphrase, let requiresEntry) = credential else {
|
||||
Issue.record("Saved passphrase must be usable silently")
|
||||
continue
|
||||
}
|
||||
#expect(!requiresEntry)
|
||||
#expect(passphrase?.matches("saved") == true)
|
||||
}
|
||||
#expect(
|
||||
SSHAuthenticationPromptResolver.host(
|
||||
Host(name: "Host", host: "example.invalid", identityID: identity.id), using: store
|
||||
).requiresPassphrase == false)
|
||||
try store.saveKeyPassphrase("replacement", for: key.id)
|
||||
let second = try store.additionalConnectionRequest(from: snapshot)
|
||||
defer { second.configuration.clearSecrets() }
|
||||
guard case .privateKey(_, _, let passphrase, _) = second.configuration.credentials.first else {
|
||||
Issue.record("Expected refreshed key")
|
||||
return
|
||||
}
|
||||
#expect(passphrase?.matches("replacement") == true)
|
||||
}
|
||||
|
||||
@Test func preparationUsesSavedPassphrasesWithoutPrompts() async throws {
|
||||
let secrets = InMemorySecretStore()
|
||||
let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true)
|
||||
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
|
||||
let host = Host(name: "Host", host: "example.invalid", identityID: identity.id)
|
||||
try secrets.setKeyCredential(
|
||||
.software(.init(privateKey: "encrypted", passphrase: "saved")), for: key.credentialRef, storage: key.storage
|
||||
)
|
||||
let preparation = SSHConnectionPreparation(
|
||||
nodes: [
|
||||
.init(host: host, identity: identity, key: key, submission: nil, trustedKeys: [], proxyIdentity: nil)
|
||||
], secrets: secrets)
|
||||
let configuration = try await preparation.resolve(trustPolicy: .allowChallenge)
|
||||
defer { configuration.clearSecrets() }
|
||||
guard case .privateKey(_, _, let passphrase, let requiresEntry) = configuration.credentials.first else {
|
||||
Issue.record("Expected saved key")
|
||||
return
|
||||
}
|
||||
#expect(!requiresEntry && passphrase?.matches("saved") == true)
|
||||
}
|
||||
|
||||
@Test(arguments: [false, true], [false, true])
|
||||
func clearingOrDeletingAKeyZeroesRollbackSecrets(delete: Bool, failsSave: Bool) throws {
|
||||
let observation = ZeroizedAllocations()
|
||||
let secrets = TrackedVaultSecrets(observation: observation)
|
||||
let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true)
|
||||
let persistence = PassphrasePersistence(snapshot: VaultSnapshot(keys: [key]))
|
||||
let store = HostLibraryModel(persistence: persistence, secrets: secrets)
|
||||
try secrets.setKeyCredential(
|
||||
.software(.init(privateKey: "encrypted", passphrase: "saved")), for: key.credentialRef, storage: key.storage
|
||||
)
|
||||
persistence.failsSave = failsSave
|
||||
do {
|
||||
if delete {
|
||||
try store.delete(keyID: key.id)
|
||||
} else {
|
||||
try store.upsert(key, credential: .software(.init(privateKey: "encrypted")))
|
||||
}
|
||||
#expect(!failsSave)
|
||||
} catch { #expect(failsSave) }
|
||||
#expect(observation.count == 2 && observation.allZero)
|
||||
let current = try secrets.keyCredential(for: key.credentialRef, storage: key.storage)
|
||||
if delete && !failsSave {
|
||||
#expect(current == nil && store.key(key.id) == nil)
|
||||
} else {
|
||||
guard case .software(let value)? = current else {
|
||||
Issue.record("Missing key")
|
||||
return
|
||||
}
|
||||
#expect(value.privateKey == "encrypted")
|
||||
#expect(value.passphrase == (failsSave ? "saved" : nil))
|
||||
#expect(store.key(key.id) != nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func preparationFailureZeroesSavedSecretsEvenWhileAliasesRemainAlive() async {
|
||||
let observation = ZeroizedAllocations()
|
||||
let secrets = RetainedPreparationSecrets(observation: observation)
|
||||
let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true)
|
||||
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
|
||||
let host = Host(name: "Host", host: "example.invalid", identityID: identity.id)
|
||||
let preparation = SSHConnectionPreparation(
|
||||
nodes: [
|
||||
.init(host: host, identity: identity, key: key, submission: nil, trustedKeys: [], proxyIdentity: nil)
|
||||
], secrets: secrets)
|
||||
await #expect(throws: TermindSSHError.self) { _ = try await preparation.resolve() }
|
||||
#expect(secrets.key.isConsumed && secrets.passphrase.isConsumed)
|
||||
#expect(observation.count == 2 && observation.allZero)
|
||||
}
|
||||
|
||||
@Test func cancellationAfterKeychainReadZeroesSavedSecrets() async {
|
||||
let observation = ZeroizedAllocations()
|
||||
let (events, continuation) = AsyncStream<Void>.makeStream()
|
||||
let gate = DispatchSemaphore(value: 0)
|
||||
let secrets = RetainedPreparationSecrets(observation: observation) {
|
||||
continuation.yield(())
|
||||
gate.wait()
|
||||
}
|
||||
let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true)
|
||||
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
|
||||
let host = Host(name: "Host", host: "example.invalid", identityID: identity.id)
|
||||
let preparation = SSHConnectionPreparation(
|
||||
nodes: [
|
||||
.init(host: host, identity: identity, key: key, submission: nil, trustedKeys: [], proxyIdentity: nil)
|
||||
], secrets: secrets)
|
||||
let task = Task { try await preparation.resolve(trustPolicy: .allowChallenge) }
|
||||
var iterator = events.makeAsyncIterator()
|
||||
_ = await iterator.next()
|
||||
task.cancel()
|
||||
gate.signal()
|
||||
await #expect(throws: CancellationError.self) { _ = try await task.value }
|
||||
#expect(observation.count == 2 && observation.allZero)
|
||||
continuation.finish()
|
||||
}
|
||||
|
||||
@Test func replacedSavedPassphraseAndAllAttemptBuffersAreZeroedBeforeDeallocation() throws {
|
||||
let observation = ZeroizedAllocations()
|
||||
let key = SSHSecret("private-key")
|
||||
let saved = SSHSecret("saved-passphrase")
|
||||
let override = SSHSecret("override-passphrase")
|
||||
for secret in [key, saved, override] { secret.observeZeroization { observation.record($0) } }
|
||||
let credential = SSHResolvedKeyCredential.software(key, passphrase: saved).sshCredential(
|
||||
publicKey: "public", passphraseOverride: override, requiresPassphrase: true)
|
||||
#expect(observation.count == 1)
|
||||
credential.clearSecrets()
|
||||
#expect(observation.count == 3)
|
||||
#expect(observation.allZero)
|
||||
}
|
||||
|
||||
@Test(arguments: [false, true])
|
||||
func consumingSavedPassphraseZeroesAllocationOnSuccessAndThrow(fails: Bool) throws {
|
||||
let observation = ZeroizedAllocations()
|
||||
let secret = SSHSecret("saved")
|
||||
secret.observeZeroization { observation.record($0) }
|
||||
do {
|
||||
try secret.withUnsafeCStringAndConsume { _, _ in
|
||||
if fails { throw CancellationError() }
|
||||
}
|
||||
} catch { #expect(fails && error is CancellationError) }
|
||||
#expect(observation.count == 1 && observation.allZero)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated final class ZeroizedAllocations: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var observations: [Bool] = []
|
||||
var count: Int { lock.withLock { observations.count } }
|
||||
var allZero: Bool { lock.withLock { observations.allSatisfy { $0 } } }
|
||||
func record(_ bytes: UnsafeRawBufferPointer) {
|
||||
lock.withLock { observations.append(!bytes.isEmpty && bytes.allSatisfy { $0 == 0 }) }
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated final class RetainedPreparationSecrets: SSHRuntimeSecretResolving, Sendable {
|
||||
let key = SSHSecret("encrypted")
|
||||
let passphrase = SSHSecret("saved")
|
||||
let onRead: @Sendable () -> Void
|
||||
init(observation: ZeroizedAllocations, onRead: @escaping @Sendable () -> Void = {}) {
|
||||
self.onRead = onRead
|
||||
key.observeZeroization { observation.record($0) }
|
||||
passphrase.observeZeroization { observation.record($0) }
|
||||
}
|
||||
func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret? { nil }
|
||||
func sshKeyCredential(for account: String, storage: KeyStorage) throws -> SSHResolvedKeyCredential? {
|
||||
onRead()
|
||||
return .software(key, passphrase: passphrase)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated final class TrackedVaultSecrets: SecretStoring, Sendable {
|
||||
let base = InMemorySecretStore()
|
||||
let observation: ZeroizedAllocations
|
||||
init(observation: ZeroizedAllocations) { self.observation = observation }
|
||||
func string(for account: String, storage: PasswordStorage) throws -> String? {
|
||||
try base.string(for: account, storage: storage)
|
||||
}
|
||||
func setString(_ value: String?, for account: String, storage: PasswordStorage) throws {
|
||||
try base.setString(value, for: account, storage: storage)
|
||||
}
|
||||
func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? {
|
||||
try base.keyCredential(for: account, storage: storage)
|
||||
}
|
||||
func sshKeyCredential(for account: String, storage: KeyStorage) throws -> SSHResolvedKeyCredential? {
|
||||
let result = try base.sshKeyCredential(for: account, storage: storage)
|
||||
if case .software(let key, let passphrase) = result {
|
||||
key.observeZeroization { [observation] in observation.record($0) }
|
||||
passphrase?.observeZeroization { [observation] in observation.record($0) }
|
||||
}
|
||||
return result
|
||||
}
|
||||
func setKeyCredential(_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage) throws {
|
||||
try base.setKeyCredential(value, for: account, storage: storage)
|
||||
}
|
||||
func removePassword(_ account: String, storage: PasswordStorage) throws {
|
||||
try base.removePassword(account, storage: storage)
|
||||
}
|
||||
func removeKeyCredential(_ account: String, storage: KeyStorage) throws {
|
||||
try base.removeKeyCredential(account, storage: storage)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private final class PassphrasePersistence: VaultPersisting {
|
||||
var snapshot: VaultSnapshot
|
||||
var failsSave = false
|
||||
init(snapshot: VaultSnapshot) { self.snapshot = snapshot }
|
||||
func load() throws -> VaultSnapshot { snapshot }
|
||||
func save(_ snapshot: VaultSnapshot, touching keys: Set<VaultRecordKey>) throws -> VaultMutationSet {
|
||||
if failsSave { throw CocoaError(.fileWriteUnknown) }
|
||||
self.snapshot = snapshot
|
||||
return .empty
|
||||
}
|
||||
func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult {
|
||||
VaultRemoteApplyResult(snapshot: snapshot, acceptedKeys: [], needsUpload: [])
|
||||
}
|
||||
}
|
||||
@@ -19574,6 +19574,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Passphrase Saved" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "已保存密码短语"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "已儲存金鑰密碼"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Passphrase required" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -23891,6 +23908,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Save Passphrase" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "保存密码短语"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "儲存金鑰密碼"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Save Workspace Template" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
|
||||
@@ -59,6 +59,7 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable {
|
||||
func sshCredentials(for identity: Identity) -> SSHCredentialResolution
|
||||
func passwordSecret(for identity: Identity) throws -> SSHSecret?
|
||||
func passwordAvailability(for identity: Identity) -> SecretAvailability
|
||||
func saveKeyPassphrase(_ passphrase: String, for keyID: SSHKeyRef.ID) throws
|
||||
func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential?
|
||||
func setConnectionStatus(_ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID)
|
||||
func recordRemoteSystemInfo(_ systemInfo: RemoteSystemInfo?, for hostID: Host.ID)
|
||||
@@ -69,6 +70,36 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable {
|
||||
}
|
||||
|
||||
extension RemoteSessionHostStore {
|
||||
public func saveKeyPassphrase(_ passphrase: String, for keyID: SSHKeyRef.ID) throws {
|
||||
throw TermindSSHError.missingCredential
|
||||
}
|
||||
|
||||
public func passphraseSubmission(
|
||||
for prompt: SSHAuthenticationPrompt, existing: SSHAuthenticationSubmission?,
|
||||
passphrase: String?, save: Bool
|
||||
) throws -> SSHAuthenticationSubmission {
|
||||
guard let passphrase, !passphrase.isEmpty else { throw TermindSSHError.keyPassphraseRequired }
|
||||
var submission =
|
||||
existing
|
||||
?? SSHAuthenticationSubmission(
|
||||
username: prompt.username, keyID: prompt.selectedKeyID, identityID: prompt.selectedIdentityID)
|
||||
guard let keyID = submission.keyID else { throw TermindSSHError.missingCredential }
|
||||
if save && prompt.allowsSaving { try saveKeyPassphrase(passphrase, for: keyID) }
|
||||
submission.passphrase = passphrase
|
||||
return submission
|
||||
}
|
||||
|
||||
/// Query the local Keychain rather than relying on synchronized metadata.
|
||||
/// Every probe releases its private key and passphrase immediately.
|
||||
public func requiresPassphraseEntry(for key: SSHKeyRef) -> Bool {
|
||||
guard key.hasPassphrase else { return false }
|
||||
do {
|
||||
guard let credential = try keySecretCredential(for: key) else { return true }
|
||||
defer { credential.clearSecrets() }
|
||||
return !credential.hasSavedPassphrase
|
||||
} catch { return true }
|
||||
}
|
||||
|
||||
public func snippet(_ id: Snippet.ID) -> Snippet? { nil }
|
||||
public func customIdentity(for hostID: Host.ID, purpose: HostCustomIdentityPurpose) -> Identity? { nil }
|
||||
public var customPasswordStorage: PasswordStorage { .iCloud }
|
||||
@@ -292,6 +323,15 @@ extension RemoteSessionHostStore {
|
||||
public func additionalConnectionRequest(
|
||||
from snapshot: SSHAdditionalConnectionSnapshot
|
||||
) throws -> SSHConnectionRequest {
|
||||
var completed = false
|
||||
var resolvedCredentials: [SSHCredentials] = []
|
||||
var resolvedProxies: [SSHSecret] = []
|
||||
defer {
|
||||
if !completed {
|
||||
for credential in resolvedCredentials { credential.clearSecrets() }
|
||||
for secret in resolvedProxies { secret.clear() }
|
||||
}
|
||||
}
|
||||
func proxy(
|
||||
_ value: SSHAdditionalConnectionSnapshot.Proxy?, ownerHostID: Host.ID
|
||||
) throws -> SSHTransportProxy? {
|
||||
@@ -307,6 +347,7 @@ extension RemoteSessionHostStore {
|
||||
}
|
||||
let password = try connectionPasswordSecret(for: identity)
|
||||
guard let password else { throw TermindSSHError.missingCredential }
|
||||
resolvedProxies.append(password)
|
||||
return SSHTransportProxy(
|
||||
kind: value.kind, hostname: value.hostname, port: value.port,
|
||||
username: value.username, password: password,
|
||||
@@ -332,7 +373,7 @@ extension RemoteSessionHostStore {
|
||||
let keyID = resolvedIdentity?.keyID ?? reference.fallbackKeyID
|
||||
if let keyID {
|
||||
if let key = key(keyID) {
|
||||
if key.requiresPassphraseEntry || key.requiresUserPresence {
|
||||
if key.requiresUserPresence || requiresPassphraseEntry(for: key) {
|
||||
credentials.append(.interactionRequired)
|
||||
} else {
|
||||
do {
|
||||
@@ -373,6 +414,7 @@ extension RemoteSessionHostStore {
|
||||
if reference.hasEphemeralSecret { throw SSHInteractionRequired() }
|
||||
if !reference.permitsNone { throw failures.first ?? TermindSSHError.missingCredential }
|
||||
}
|
||||
resolvedCredentials += credentials
|
||||
return (username, credentials)
|
||||
}
|
||||
|
||||
@@ -396,6 +438,7 @@ extension RemoteSessionHostStore {
|
||||
charset: snapshot.target.charset,
|
||||
authenticationReference: snapshot.target.authentication
|
||||
)
|
||||
completed = true
|
||||
return SSHConnectionRequest(
|
||||
configuration: configuration, interactionPolicy: .forbidden
|
||||
).enforcingInteractionPolicy()
|
||||
|
||||
@@ -68,7 +68,9 @@ public enum SSHAuthenticationPromptResolver {
|
||||
let needsPassphrase =
|
||||
requiresPassphrase
|
||||
?? (submission?.requiresPasswordReentry != true
|
||||
&& keyID.flatMap(store.key)?.requiresPassphraseEntry == true
|
||||
&& keyID.flatMap(store.key).map {
|
||||
(mode == .serverChallenge && $0.hasPassphrase) || store.requiresPassphraseEntry(for: $0)
|
||||
} == true
|
||||
&& submission?.passphrase == nil)
|
||||
return SSHAuthenticationPrompt(
|
||||
host: host, selectedIdentityID: submission?.identityID ?? identity?.id,
|
||||
|
||||
@@ -44,7 +44,10 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
|
||||
}
|
||||
return try await withTaskCancellationHandler {
|
||||
let result = try await task.value
|
||||
try Task.checkCancellation()
|
||||
do { try Task.checkCancellation() } catch {
|
||||
result.clearSecrets()
|
||||
throw error
|
||||
}
|
||||
return result
|
||||
} onCancel: {
|
||||
task.cancel()
|
||||
@@ -58,10 +61,22 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
|
||||
throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts."))
|
||||
}
|
||||
var jumps: [SSHRouteHopConfiguration] = []
|
||||
var completed = false
|
||||
var resolvedCredentials: [SSHCredentials] = []
|
||||
var resolvedProxies: [SSHSecret] = []
|
||||
defer {
|
||||
if !completed {
|
||||
for credential in resolvedCredentials { credential.clearSecrets() }
|
||||
for secret in resolvedProxies { secret.clear() }
|
||||
}
|
||||
}
|
||||
for (offset, node) in nodes.dropLast().enumerated() {
|
||||
do {
|
||||
try Task.checkCancellation()
|
||||
let material = try credentials(node)
|
||||
resolvedCredentials += material.1
|
||||
let resolvedProxy = try proxy(node)
|
||||
if let password = resolvedProxy?.password { resolvedProxies.append(password) }
|
||||
if trustPolicy == .requireKnownHost, node.trustedKeys.isEmpty {
|
||||
throw TermindSSHError.untrustedHost
|
||||
}
|
||||
@@ -70,7 +85,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
|
||||
id: node.host.id, name: node.host.name, host: node.host.host, port: node.host.port,
|
||||
username: material.0, credentials: material.1,
|
||||
expectedHostKeys: node.trustedKeys,
|
||||
proxy: try proxy(node),
|
||||
proxy: resolvedProxy,
|
||||
authenticationReference: authenticationReference(for: node)
|
||||
))
|
||||
} catch {
|
||||
@@ -90,13 +105,17 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
|
||||
discoveryOnly
|
||||
? ("", [SSHCredentials]())
|
||||
: try credentials(target)
|
||||
resolvedCredentials += material.1
|
||||
let resolvedProxy = try proxy(target)
|
||||
if let password = resolvedProxy?.password { resolvedProxies.append(password) }
|
||||
if !discoveryOnly, trustPolicy == .requireKnownHost, target.trustedKeys.isEmpty {
|
||||
throw TermindSSHError.untrustedHost
|
||||
}
|
||||
completed = true
|
||||
return SSHConnectionConfiguration(
|
||||
hostID: target.host.id, host: target.host.host, port: target.host.port,
|
||||
username: material.0, credentials: material.1, expectedHostKeys: target.trustedKeys,
|
||||
proxy: try proxy(target), jumps: jumps,
|
||||
proxy: resolvedProxy, jumps: jumps,
|
||||
charset: target.host.charset,
|
||||
authenticationReference: authenticationReference(for: target)
|
||||
)
|
||||
@@ -126,11 +145,10 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !username.isEmpty else { throw TermindSSHError.missingUsername }
|
||||
var result: [SSHCredentials] = []
|
||||
var completed = false
|
||||
defer { if !completed { for credential in result { credential.clearSecrets() } } }
|
||||
var retrievalFailure: (any Error)?
|
||||
if let key = node.key {
|
||||
if key.requiresPassphraseEntry, node.submission?.hasPassphrase != true {
|
||||
throw TermindSSHError.keyPassphraseRequired
|
||||
}
|
||||
let stored: SSHResolvedKeyCredential?
|
||||
do {
|
||||
stored = try secrets.sshKeyCredential(for: key.credentialRef, storage: key.storage)
|
||||
@@ -139,6 +157,10 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
|
||||
retrievalFailure = error
|
||||
}
|
||||
if let stored {
|
||||
if key.hasPassphrase, !stored.hasSavedPassphrase, node.submission?.hasPassphrase != true {
|
||||
stored.clearSecrets()
|
||||
throw TermindSSHError.keyPassphraseRequired
|
||||
}
|
||||
result.append(
|
||||
stored.sshCredential(
|
||||
publicKey: key.publicKey, certificate: key.certificate,
|
||||
@@ -169,6 +191,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
|
||||
if result.isEmpty, node.key != nil || (usesIdentity && node.identity?.hasSecret == true) {
|
||||
throw TermindSSHError.missingCredential
|
||||
}
|
||||
completed = true
|
||||
return (username, result)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ public final class CredentialLibraryModel {
|
||||
public func editablePassword(for identity: Identity) throws -> String? {
|
||||
try library.editablePassword(for: identity)
|
||||
}
|
||||
public func hasSavedPassphrase(for key: SSHKeyRef) -> Bool {
|
||||
key.hasPassphrase && !library.requiresPassphraseEntry(for: key)
|
||||
}
|
||||
public func keyAvailability(for key: SSHKeyRef) -> SecretAvailability {
|
||||
library.keyAvailability(for: key)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
/// The latest local persistence failure. Secret text is never included.
|
||||
public private(set) var storageError: String?
|
||||
private let vault: VaultStore
|
||||
private var keyCredentialRevision = 0
|
||||
private let keychain: any SecretStoring
|
||||
private let defaults: UserDefaults
|
||||
private var expansionState = LibraryExpansionState()
|
||||
@@ -730,7 +731,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
private func sshCredentials(for submission: SSHAuthenticationSubmission) throws -> [SSHCredentials] {
|
||||
var credentials: [SSHCredentials] = []
|
||||
if let keyID = submission.keyID, let key = key(keyID) {
|
||||
if key.requiresPassphraseEntry, !submission.hasPassphrase {
|
||||
if requiresPassphraseEntry(for: key), !submission.hasPassphrase {
|
||||
throw TermindSSHError.keyPassphraseRequired
|
||||
}
|
||||
if let stored = try keySecretCredential(for: key) {
|
||||
@@ -802,7 +803,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
|
||||
if let keyID = identity.keyID {
|
||||
if let key = key(keyID) {
|
||||
if key.requiresPassphraseEntry {
|
||||
if requiresPassphraseEntry(for: key) {
|
||||
requiredPassphraseKeyID = key.id
|
||||
} else {
|
||||
do {
|
||||
@@ -1469,11 +1470,15 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
}
|
||||
let previousCredential =
|
||||
try (credential != nil || changesLocation)
|
||||
? previousKey.flatMap { try keyCredential(for: $0) }
|
||||
? previousKey.flatMap { try keySecretCredential(for: $0) }
|
||||
: nil
|
||||
let destinationCredential = credential ?? (changesLocation ? previousCredential : nil)
|
||||
let destinationCredential = credential?.resolvedCredential() ?? (changesLocation ? previousCredential : nil)
|
||||
defer {
|
||||
previousCredential?.clearSecrets()
|
||||
destinationCredential?.clearSecrets()
|
||||
}
|
||||
if let destinationCredential {
|
||||
try keychain.setKeyCredential(
|
||||
try keychain.setResolvedKeyCredential(
|
||||
destinationCredential, for: key.credentialRef, storage: key.storage
|
||||
)
|
||||
}
|
||||
@@ -1494,7 +1499,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
key.credentialRef, storage: key.storage
|
||||
)
|
||||
} else if destinationCredential != nil, let previousKey, let previousCredential {
|
||||
try keychain.setKeyCredential(
|
||||
try keychain.setResolvedKeyCredential(
|
||||
previousCredential, for: previousKey.credentialRef,
|
||||
storage: previousKey.storage
|
||||
)
|
||||
@@ -1510,6 +1515,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
storageError ?? String(localized: "The key could not be saved.")
|
||||
)
|
||||
}
|
||||
if destinationCredential != nil { keyCredentialRevision &+= 1 }
|
||||
if changesLocation, let previousKey {
|
||||
do {
|
||||
try keychain.removeKeyCredential(
|
||||
@@ -1551,8 +1557,19 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
try keychain.keyCredential(for: key.credentialRef, storage: key.storage)
|
||||
}
|
||||
|
||||
public func saveKeyPassphrase(_ passphrase: String, for keyID: SSHKeyRef.ID) throws {
|
||||
guard !passphrase.isEmpty, let key = key(keyID), key.hasPassphrase, key.storage != .secureEnclave else {
|
||||
throw TermindSSHError.missingCredential
|
||||
}
|
||||
let secret = SSHSecret(passphrase)
|
||||
defer { secret.clear() }
|
||||
try keychain.setKeyPassphrase(secret, for: key.credentialRef, storage: key.storage)
|
||||
keyCredentialRevision &+= 1
|
||||
}
|
||||
|
||||
public func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential? {
|
||||
try keychain.sshKeyCredential(for: key.credentialRef, storage: key.storage)
|
||||
_ = keyCredentialRevision
|
||||
return try keychain.sshKeyCredential(for: key.credentialRef, storage: key.storage)
|
||||
}
|
||||
|
||||
public func keyAvailability(for key: SSHKeyRef) -> SecretAvailability {
|
||||
@@ -1567,7 +1584,8 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
|
||||
public func delete(keyID: SSHKeyRef.ID) throws {
|
||||
guard let key = key(keyID) else { return }
|
||||
let credential = try keyCredential(for: key)
|
||||
let credential = try keySecretCredential(for: key)
|
||||
defer { credential?.clearSecrets() }
|
||||
try keychain.removeKeyCredential(key.credentialRef, storage: key.storage)
|
||||
let keySnapshot = keys
|
||||
let identitySnapshot = identities
|
||||
@@ -1579,7 +1597,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
keys = keySnapshot
|
||||
identities = identitySnapshot
|
||||
if let credential {
|
||||
try keychain.setKeyCredential(
|
||||
try keychain.setResolvedKeyCredential(
|
||||
credential, for: key.credentialRef, storage: key.storage
|
||||
)
|
||||
}
|
||||
|
||||
@@ -296,13 +296,15 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
|
||||
}
|
||||
return
|
||||
}
|
||||
if server.authenticationPrompt?.requiresPassphrase == true,
|
||||
var staged = submissions[server.id]?[host.id]
|
||||
{
|
||||
guard let passphrase, !passphrase.isEmpty else { return }
|
||||
staged.passphrase = passphrase
|
||||
submissions[server.id]?[host.id] = staged
|
||||
retry(server)
|
||||
if let prompt = server.authenticationPrompt, prompt.requiresPassphrase {
|
||||
do {
|
||||
submissions[server.id, default: [:]][host.id] = try hosts.passphraseSubmission(
|
||||
for: prompt, existing: submissions[server.id]?[host.id], passphrase: passphrase, save: save)
|
||||
retry(server)
|
||||
} catch {
|
||||
server.authenticationPrompt?.failureMessage = error.localizedDescription
|
||||
server.status = error.localizedDescription
|
||||
}
|
||||
return
|
||||
}
|
||||
let chosenKey = identity?.keyID ?? keyID
|
||||
@@ -367,7 +369,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
|
||||
let keyID = staged?.keyID ?? identity?.keyID
|
||||
let passphrase =
|
||||
staged?.requiresPasswordReentry != true
|
||||
&& keyID.flatMap { hosts.key($0) }?.requiresPassphraseEntry == true
|
||||
&& keyID.flatMap { hosts.key($0) }?.hasPassphrase == true
|
||||
&& staged?.passphrase == nil
|
||||
server.authenticationHost = host
|
||||
server.authenticationPrompt = SSHAuthenticationPromptResolver.host(
|
||||
@@ -730,7 +732,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
|
||||
)
|
||||
}
|
||||
if let passphraseHost = submissions[server.id]?.firstHostRequiringPassphrase(
|
||||
in: route, key: hosts.key
|
||||
in: route, key: hosts.key, requiresEntry: hosts.requiresPassphraseEntry
|
||||
) {
|
||||
throw TermindSSHError.routeHost(
|
||||
hostID: passphraseHost.id, authentication: true,
|
||||
|
||||
@@ -329,25 +329,39 @@ public nonisolated enum SSHCredentials: Sendable {
|
||||
}
|
||||
|
||||
public nonisolated enum SSHResolvedKeyCredential: Sendable {
|
||||
case software(SSHSecret)
|
||||
case software(SSHSecret, passphrase: SSHSecret? = nil)
|
||||
case secureEnclave(SecureEnclaveKeyCredential)
|
||||
|
||||
public var hasSavedPassphrase: Bool {
|
||||
if case .software(_, let passphrase) = self { return passphrase != nil && passphrase?.isConsumed == false }
|
||||
return false
|
||||
}
|
||||
|
||||
public func clearSecrets() {
|
||||
if case .software(let privateKey, let passphrase) = self {
|
||||
privateKey.clear()
|
||||
passphrase?.clear()
|
||||
}
|
||||
}
|
||||
|
||||
public func sshCredential(
|
||||
publicKey: String, certificate: String = "", passphraseOverride: SSHSecret? = nil,
|
||||
requiresPassphrase: Bool = false, requiresUserPresence: Bool = false
|
||||
) -> SSHCredentials {
|
||||
switch self {
|
||||
case .software(let privateKey):
|
||||
.privateKey(
|
||||
case .software(let privateKey, let savedPassphrase):
|
||||
let hasSavedPassphrase = self.hasSavedPassphrase
|
||||
if let passphraseOverride, savedPassphrase !== passphraseOverride { savedPassphrase?.clear() }
|
||||
return .privateKey(
|
||||
privateKey: privateKey,
|
||||
publicKey: certificate.isEmpty
|
||||
? (publicKey.isEmpty ? nil : publicKey)
|
||||
: certificate,
|
||||
passphrase: passphraseOverride,
|
||||
requiresPassphrase: requiresPassphrase
|
||||
passphrase: passphraseOverride ?? savedPassphrase,
|
||||
requiresPassphrase: requiresPassphrase && !hasSavedPassphrase
|
||||
)
|
||||
case .secureEnclave(let credential):
|
||||
.secureEnclave(
|
||||
return .secureEnclave(
|
||||
credential: credential, publicKey: publicKey,
|
||||
requiresUserPresence: requiresUserPresence
|
||||
)
|
||||
@@ -356,13 +370,23 @@ public nonisolated enum SSHResolvedKeyCredential: Sendable {
|
||||
}
|
||||
|
||||
nonisolated extension StoredSSHKeyCredential {
|
||||
public func resolvedCredential() -> SSHResolvedKeyCredential {
|
||||
switch self {
|
||||
case .software(let secret):
|
||||
.software(SSHSecret(secret.privateKey), passphrase: secret.passphrase.map(SSHSecret.init))
|
||||
case .secureEnclave(let key): .secureEnclave(key)
|
||||
}
|
||||
}
|
||||
|
||||
public func sshCredential(
|
||||
publicKey: String, certificate: String = "", passphraseOverride: SSHSecret? = nil,
|
||||
requiresPassphrase: Bool = false, requiresUserPresence: Bool = false
|
||||
) -> SSHCredentials {
|
||||
switch self {
|
||||
case .software(let secret):
|
||||
SSHResolvedKeyCredential.software(SSHSecret(secret.privateKey)).sshCredential(
|
||||
SSHResolvedKeyCredential.software(
|
||||
SSHSecret(secret.privateKey), passphrase: secret.passphrase.map(SSHSecret.init)
|
||||
).sshCredential(
|
||||
publicKey: publicKey, certificate: certificate,
|
||||
passphraseOverride: passphraseOverride,
|
||||
requiresPassphrase: requiresPassphrase,
|
||||
|
||||
@@ -131,13 +131,14 @@ nonisolated extension Dictionary where Key == Host.ID, Value == SSHAuthenticatio
|
||||
}
|
||||
|
||||
public func firstHostRequiringPassphrase(
|
||||
in route: [Host], key: (SSHKeyRef.ID) -> SSHKeyRef?
|
||||
in route: [Host], key: (SSHKeyRef.ID) -> SSHKeyRef?,
|
||||
requiresEntry: (SSHKeyRef) -> Bool = { $0.requiresPassphraseEntry }
|
||||
) -> 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
|
||||
return key(keyID).map(requiresEntry) == true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,11 @@ public nonisolated final class SSHSecret: @unchecked Sendable {
|
||||
private struct Storage {
|
||||
var pointer: UnsafeMutableRawPointer
|
||||
var count: Int
|
||||
var didZeroize: (@Sendable (UnsafeRawBufferPointer) -> Void)?
|
||||
|
||||
func clearAndDeallocate() {
|
||||
_ = memset_s(pointer, count + 1, 0, count + 1)
|
||||
didZeroize?(UnsafeRawBufferPointer(start: pointer, count: count + 1))
|
||||
pointer.deallocate()
|
||||
}
|
||||
}
|
||||
@@ -63,6 +65,8 @@ public nonisolated final class SSHSecret: @unchecked Sendable {
|
||||
|
||||
deinit { clear() }
|
||||
|
||||
public var byteCount: Int { lock.withLock { storage?.count ?? 0 } }
|
||||
|
||||
public var isConsumed: Bool {
|
||||
lock.withLock { storage == nil }
|
||||
}
|
||||
@@ -101,6 +105,12 @@ public nonisolated final class SSHSecret: @unchecked Sendable {
|
||||
storage.clearAndDeallocate()
|
||||
}
|
||||
|
||||
/// Internal test seam: observes the allocation only after memset_s and
|
||||
/// before deallocation, never after the pointer has become invalid.
|
||||
func observeZeroization(_ observer: @escaping @Sendable (UnsafeRawBufferPointer) -> Void) {
|
||||
lock.withLock { storage?.didZeroize = observer }
|
||||
}
|
||||
|
||||
private func take() throws -> Storage {
|
||||
try lock.withLock {
|
||||
guard let value = storage else { throw Consumed() }
|
||||
|
||||
@@ -3,8 +3,10 @@ import TermindDomain
|
||||
|
||||
public nonisolated struct SSHPrivateKeySecret: Codable, Hashable, Sendable {
|
||||
public var privateKey: String
|
||||
public init(privateKey: String) {
|
||||
public var passphrase: String?
|
||||
public init(privateKey: String, passphrase: String? = nil) {
|
||||
self.privateKey = privateKey
|
||||
self.passphrase = passphrase
|
||||
}
|
||||
|
||||
}
|
||||
@@ -23,10 +25,13 @@ public nonisolated protocol SecretStoring: SSHRuntimeSecretResolving {
|
||||
func keyCredential(
|
||||
for account: String, storage: KeyStorage
|
||||
) throws -> StoredSSHKeyCredential?
|
||||
func setKeyPassphrase(_ passphrase: SSHSecret?, for account: String, storage: KeyStorage) throws
|
||||
func containsKeyCredential(for account: String, storage: KeyStorage) throws -> Bool
|
||||
func setKeyCredential(
|
||||
_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage
|
||||
) throws
|
||||
func setResolvedKeyCredential(_ credential: SSHResolvedKeyCredential, for account: String, storage: KeyStorage)
|
||||
throws
|
||||
func removePassword(_ account: String, storage: PasswordStorage) throws
|
||||
func removeKeyCredential(_ account: String, storage: KeyStorage) throws
|
||||
}
|
||||
@@ -40,6 +45,39 @@ nonisolated extension SecretStoring {
|
||||
try keyCredential(for: account, storage: storage) != nil
|
||||
}
|
||||
|
||||
/// Default for in-memory/test stores. The Keychain adapter overrides this
|
||||
/// to update its JSON envelope entirely through wipeable byte buffers.
|
||||
public func setKeyPassphrase(_ passphrase: SSHSecret?, for account: String, storage: KeyStorage) throws {
|
||||
defer { passphrase?.clear() }
|
||||
guard case .software(var secret)? = try keyCredential(for: account, storage: storage) else {
|
||||
throw TermindSSHError.missingCredential
|
||||
}
|
||||
secret.passphrase = try passphrase?.withUnsafeCStringAndConsume { bytes, count in
|
||||
String(decoding: UnsafeRawBufferPointer(start: bytes, count: count), as: UTF8.self)
|
||||
}
|
||||
try setKeyCredential(.software(secret), for: account, storage: storage)
|
||||
}
|
||||
|
||||
/// Test/in-memory fallback; persistent adapters must write the wipeable
|
||||
/// credential directly, without first materializing String secrets.
|
||||
public func setResolvedKeyCredential(
|
||||
_ credential: SSHResolvedKeyCredential, for account: String, storage: KeyStorage
|
||||
) throws {
|
||||
defer { credential.clearSecrets() }
|
||||
switch credential {
|
||||
case .software(let key, let passphrase):
|
||||
let text = try key.withUnsafeCStringAndConsume { bytes, count in
|
||||
String(decoding: UnsafeRawBufferPointer(start: bytes, count: count), as: UTF8.self)
|
||||
}
|
||||
let phrase = try passphrase?.withUnsafeCStringAndConsume { bytes, count in
|
||||
String(decoding: UnsafeRawBufferPointer(start: bytes, count: count), as: UTF8.self)
|
||||
}
|
||||
try setKeyCredential(.software(.init(privateKey: text, passphrase: phrase)), for: account, storage: storage)
|
||||
case .secureEnclave(let key):
|
||||
try setKeyCredential(.secureEnclave(key), for: account, storage: storage)
|
||||
}
|
||||
}
|
||||
|
||||
public func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret? {
|
||||
try string(for: account, storage: storage).map(SSHSecret.init)
|
||||
}
|
||||
@@ -48,7 +86,8 @@ nonisolated extension SecretStoring {
|
||||
for account: String, storage: KeyStorage
|
||||
) throws -> SSHResolvedKeyCredential? {
|
||||
switch try keyCredential(for: account, storage: storage) {
|
||||
case .software(let secret): .software(SSHSecret(secret.privateKey))
|
||||
case .software(let secret):
|
||||
.software(SSHSecret(secret.privateKey), passphrase: secret.passphrase.map(SSHSecret.init))
|
||||
case .secureEnclave(let credential): .secureEnclave(credential)
|
||||
case nil: nil
|
||||
}
|
||||
|
||||
@@ -111,9 +111,13 @@ public struct SSHAuthenticationForm: View {
|
||||
if prompt.keyboardInteractive == nil, let keyboard {
|
||||
keyboard.bridge(
|
||||
canSubmit: canContinue,
|
||||
submit: { commit(save: !needsPassphrase) }, cancel: cancel)
|
||||
submit: { commit(save: prompt.allowsSaving) }, cancel: cancel)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
password = ""
|
||||
passphrase = ""
|
||||
}
|
||||
.onAppear {
|
||||
if identityID != nil, selectedIdentity == nil { identityID = nil }
|
||||
loadPassword(for: selectedIdentity ?? initialCustomIdentity)
|
||||
@@ -226,43 +230,29 @@ public struct SSHAuthenticationForm: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var actions: some View {
|
||||
if needsPassphrase {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack {
|
||||
cancelButton
|
||||
Spacer()
|
||||
passphraseContinueButton
|
||||
}
|
||||
VStack(spacing: 10) {
|
||||
cancelButton.frame(maxWidth: .infinity, alignment: .leading)
|
||||
passphraseContinueButton.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack {
|
||||
cancelButton
|
||||
Spacer()
|
||||
continueButton
|
||||
if prompt.allowsSaving { saveAndContinueButton }
|
||||
}
|
||||
} else {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
VStack(spacing: 10) {
|
||||
HStack {
|
||||
cancelButton
|
||||
Spacer()
|
||||
continueButton
|
||||
if prompt.allowsSaving { saveAndContinueButton }
|
||||
}
|
||||
VStack(spacing: 10) {
|
||||
#if os(iOS)
|
||||
HStack {
|
||||
cancelButton
|
||||
Spacer()
|
||||
continueButton
|
||||
if prompt.allowsSaving { saveAndContinueButton }
|
||||
}
|
||||
#if os(iOS)
|
||||
HStack {
|
||||
Spacer()
|
||||
if prompt.allowsSaving { saveAndContinueButton }
|
||||
}
|
||||
#else
|
||||
if prompt.allowsSaving {
|
||||
saveAndContinueButton.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
if prompt.allowsSaving {
|
||||
saveAndContinueButton.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,7 +262,16 @@ public struct SSHAuthenticationForm: View {
|
||||
.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var continueButton: some View {
|
||||
if prompt.allowsSaving {
|
||||
baseContinueButton
|
||||
} else {
|
||||
baseContinueButton.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
|
||||
private var baseContinueButton: some View {
|
||||
Button {
|
||||
commit(save: false)
|
||||
} label: {
|
||||
@@ -281,17 +280,11 @@ public struct SSHAuthenticationForm: View {
|
||||
.disabled(!canContinue)
|
||||
}
|
||||
|
||||
private var passphraseContinueButton: some View {
|
||||
continueButton
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
|
||||
private var saveAndContinueButton: some View {
|
||||
Button {
|
||||
commit(save: true)
|
||||
} label: {
|
||||
ConnectionActionLabel(title: "Save and Continue", symbol: "checkmark")
|
||||
ConnectionActionLabel(title: AppStrings.Keychain.saveAndContinue, symbol: "checkmark")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
@@ -309,7 +302,8 @@ public struct SSHAuthenticationForm: View {
|
||||
}
|
||||
|
||||
private func commit(save: Bool) {
|
||||
let submittedPassword = save ? password : (password.isEmpty ? nil : password)
|
||||
let save = save && prompt.allowsSaving
|
||||
let submittedPassword = needsPassphrase ? nil : (save ? password : (password.isEmpty ? nil : password))
|
||||
let submittedPassphrase = passphrase.isEmpty ? nil : passphrase
|
||||
password = ""
|
||||
passphrase = ""
|
||||
|
||||
@@ -2,6 +2,9 @@ import Foundation
|
||||
|
||||
extension AppStrings {
|
||||
public nonisolated enum Keychain {
|
||||
public static var savePassphrase: LocalizedStringResource { "Save Passphrase" }
|
||||
public static var saveAndContinue: LocalizedStringResource { "Save and Continue" }
|
||||
|
||||
public static var addAndConnectToAHostBeforeExportingAKeyToIt: LocalizedStringResource {
|
||||
"Add and connect to a host before exporting a key to it."
|
||||
}
|
||||
@@ -206,6 +209,8 @@ extension AppStrings {
|
||||
"No secret"
|
||||
}
|
||||
|
||||
public static var passphraseSaved: LocalizedStringResource { "Passphrase Saved" }
|
||||
|
||||
public static var passphraseRequired: LocalizedStringResource {
|
||||
"Passphrase Required"
|
||||
}
|
||||
|
||||
@@ -311,19 +311,15 @@ public final class AgentHostResolver: Sendable {
|
||||
case .cancelled, .keyboardInteractive:
|
||||
throw AgentToolError.cancelled
|
||||
case .submit(let answer):
|
||||
if case .passphrase = requirement.mode,
|
||||
var retained = submissions[node.id]
|
||||
{
|
||||
// A protected key may have been chosen beside a password
|
||||
// in the preceding Identity step. The passphrase card only
|
||||
// returns the newly-entered secret, so merge it into that
|
||||
// staged submission instead of replacing (and silently
|
||||
// dropping) the password and other choices.
|
||||
guard let passphrase = answer.passphrase, !passphrase.isEmpty else {
|
||||
throw TermindSSHError.keyPassphraseRequired
|
||||
if case .passphrase = requirement.mode {
|
||||
do {
|
||||
submissions[node.id] = try store.passphraseSubmission(
|
||||
for: request.journeyPrompt, existing: submissions[node.id],
|
||||
passphrase: answer.passphrase, save: answer.save)
|
||||
} catch {
|
||||
failure = error.localizedDescription
|
||||
continue
|
||||
}
|
||||
retained.passphrase = passphrase
|
||||
submissions[node.id] = retained
|
||||
} else {
|
||||
submissions[node.id] = try submission(from: answer, for: node)
|
||||
}
|
||||
@@ -414,7 +410,9 @@ public final class AgentHostResolver: Sendable {
|
||||
guard forcedHostID == nil || forcedHostID == host.id else { return nil }
|
||||
if let submission = submissions[host.id] {
|
||||
if let keyID = submission.keyID,
|
||||
store.key(keyID)?.requiresPassphraseEntry == true,
|
||||
store.key(keyID).map({
|
||||
(forcedHostID == host.id && $0.hasPassphrase) || store.requiresPassphraseEntry(for: $0)
|
||||
}) == true,
|
||||
submission.passphrase?.isEmpty != false
|
||||
{
|
||||
return AuthenticationRequirement(mode: .passphrase(keyID: keyID))
|
||||
@@ -430,6 +428,9 @@ public final class AgentHostResolver: Sendable {
|
||||
guard let identity = store.identity(for: host) else {
|
||||
return AuthenticationRequirement(mode: .identity)
|
||||
}
|
||||
if forcedHostID == host.id, let keyID = identity.keyID, store.key(keyID)?.hasPassphrase == true {
|
||||
return AuthenticationRequirement(mode: .passphrase(keyID: keyID))
|
||||
}
|
||||
let resolution = store.sshCredentials(for: identity)
|
||||
if let keyID = resolution.requiredPassphraseKeyID {
|
||||
return AuthenticationRequirement(mode: .passphrase(keyID: keyID))
|
||||
|
||||
@@ -282,10 +282,16 @@ public final class PortForwardingController {
|
||||
}
|
||||
return
|
||||
}
|
||||
if prompt.requiresPassphrase, var staged = context.submissions[host.id] {
|
||||
guard let passphrase, !passphrase.isEmpty else { return }
|
||||
staged.passphrase = passphrase
|
||||
context.submissions[host.id] = staged
|
||||
if prompt.requiresPassphrase {
|
||||
do {
|
||||
context.submissions[host.id] = try hosts.passphraseSubmission(
|
||||
for: prompt, existing: context.submissions[host.id], passphrase: passphrase, save: save)
|
||||
} catch {
|
||||
var failed = prompt
|
||||
failed.failureMessage = error.localizedDescription
|
||||
context.setPrompt(failed)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
let chosenKey = identity?.keyID ?? keyID
|
||||
if save {
|
||||
@@ -374,7 +380,7 @@ public final class PortForwardingController {
|
||||
return
|
||||
}
|
||||
if let passphraseHost = context.submissions.firstHostRequiringPassphrase(
|
||||
in: context.route, key: hosts.key
|
||||
in: context.route, key: hosts.key, requiresEntry: hosts.requiresPassphraseEntry
|
||||
) {
|
||||
requestAuthentication(
|
||||
context,
|
||||
@@ -549,7 +555,7 @@ public final class PortForwardingController {
|
||||
let keyID = staged?.keyID ?? identity?.keyID
|
||||
let passphrase =
|
||||
staged?.requiresPasswordReentry != true
|
||||
&& keyID.flatMap(hosts.key)?.requiresPassphraseEntry == true
|
||||
&& keyID.flatMap(hosts.key)?.hasPassphrase == true
|
||||
&& staged?.passphrase == nil
|
||||
context.setPrompt(
|
||||
SSHAuthenticationPromptResolver.host(
|
||||
|
||||
@@ -90,11 +90,12 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
|
||||
{
|
||||
try validate(account, prefix: "termind.key.")
|
||||
guard
|
||||
let data = try data(
|
||||
var data = try data(
|
||||
for: account, service: service(for: storage),
|
||||
synchronizable: storage == .iCloud
|
||||
)
|
||||
else { return nil }
|
||||
defer { SecretMemory.clear(&data) }
|
||||
switch storage {
|
||||
case .thisDevice, .iCloud:
|
||||
return .software(try JSONDecoder().decode(SSHPrivateKeySecret.self, from: data))
|
||||
@@ -125,7 +126,7 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
|
||||
defer { SecretMemory.clear(&data) }
|
||||
switch storage {
|
||||
case .thisDevice, .iCloud:
|
||||
return .software(try Self.decodePrivateKeySecret(from: &data))
|
||||
return try Self.decodeKeySecrets(from: &data)
|
||||
case .secureEnclave:
|
||||
guard !data.isEmpty else { throw KeychainSecretError.unexpectedValue }
|
||||
return .secureEnclave(SecureEnclaveKeyCredential(dataRepresentation: data))
|
||||
@@ -159,90 +160,222 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
public func setResolvedKeyCredential(
|
||||
_ credential: SSHResolvedKeyCredential, for account: String, storage: KeyStorage
|
||||
) throws {
|
||||
defer { credential.clearSecrets() }
|
||||
try validate(account, prefix: "termind.key.")
|
||||
var encoded: Data
|
||||
switch (credential, storage) {
|
||||
case (.software(let key, let passphrase), .thisDevice), (.software(let key, let passphrase), .iCloud):
|
||||
encoded = try Self.encodeKeySecrets(privateKey: key, passphrase: passphrase)
|
||||
case (.secureEnclave(let key), .secureEnclave): encoded = key.dataRepresentation
|
||||
default: throw KeychainSecretError.unexpectedValue
|
||||
}
|
||||
func consume(_ expected: UInt8) throws {
|
||||
guard index < data.endIndex, data[index] == expected else {
|
||||
throw KeychainSecretError.unexpectedValue
|
||||
}
|
||||
index = data.index(after: index)
|
||||
}
|
||||
|
||||
skipWhitespace()
|
||||
try consume(0x7B)
|
||||
skipWhitespace()
|
||||
for expected in Array("\"privateKey\"".utf8) {
|
||||
try consume(expected)
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
guard index < data.endIndex else { throw KeychainSecretError.unexpectedValue }
|
||||
let escape = data[index]
|
||||
index = data.index(after: index)
|
||||
switch escape {
|
||||
case 0x22, 0x5C, 0x2F: result.append(escape)
|
||||
case 0x62: result.append(0x08)
|
||||
case 0x66: result.append(0x0C)
|
||||
case 0x6E: result.append(0x0A)
|
||||
case 0x72: result.append(0x0D)
|
||||
case 0x74: result.append(0x09)
|
||||
case 0x75:
|
||||
var value: UInt32 = 0
|
||||
for _ in 0..<4 {
|
||||
guard index < data.endIndex, let digit = hex(data[index]) else {
|
||||
throw KeychainSecretError.unexpectedValue
|
||||
}
|
||||
value = value * 16 + UInt32(digit)
|
||||
index = data.index(after: index)
|
||||
}
|
||||
guard let scalar = Unicode.Scalar(value), !(0xD800...0xDFFF).contains(value) else {
|
||||
throw KeychainSecretError.unexpectedValue
|
||||
}
|
||||
result.append(contentsOf: String(scalar).utf8)
|
||||
default:
|
||||
throw KeychainSecretError.unexpectedValue
|
||||
}
|
||||
}
|
||||
throw KeychainSecretError.unexpectedValue
|
||||
defer { SecretMemory.clear(&encoded) }
|
||||
try setData(
|
||||
encoded, for: account, service: service(for: storage), synchronizable: storage == .iCloud,
|
||||
accessible: storage == .iCloud
|
||||
? kSecAttrAccessibleWhenUnlocked : kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
||||
)
|
||||
}
|
||||
|
||||
private static func hex(_ byte: UInt8) -> UInt8? {
|
||||
switch byte {
|
||||
case 0x30...0x39: byte - 0x30
|
||||
case 0x41...0x46: byte - 0x41 + 10
|
||||
case 0x61...0x66: byte - 0x61 + 10
|
||||
default: nil
|
||||
public func setKeyPassphrase(_ passphrase: SSHSecret?, for account: String, storage: KeyStorage) throws {
|
||||
defer { passphrase?.clear() }
|
||||
guard storage != .secureEnclave,
|
||||
let credential = try sshKeyCredential(for: account, storage: storage),
|
||||
case .software(let privateKey, _) = credential
|
||||
else { throw KeychainSecretError.unexpectedValue }
|
||||
defer { credential.clearSecrets() }
|
||||
var encoded = try Self.encodeKeySecrets(privateKey: privateKey, passphrase: passphrase)
|
||||
defer { SecretMemory.clear(&encoded) }
|
||||
try setData(
|
||||
encoded, for: account, service: service(for: storage), synchronizable: storage == .iCloud,
|
||||
accessible: storage == .iCloud
|
||||
? kSecAttrAccessibleWhenUnlocked : kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
||||
)
|
||||
}
|
||||
|
||||
/// The editor's legacy envelope and the runtime reader share the same wire
|
||||
/// format. Secret values never become immutable Strings in the runtime path.
|
||||
static func encodeKeySecrets(privateKey: SSHSecret, passphrase: SSHSecret?) throws -> Data {
|
||||
defer {
|
||||
privateKey.clear()
|
||||
passphrase?.clear()
|
||||
}
|
||||
var output = Data()
|
||||
var succeeded = false
|
||||
defer { if !succeeded { SecretMemory.clear(&output) } }
|
||||
func append(_ secret: SSHSecret) throws {
|
||||
output.append(0x22)
|
||||
try secret.withUnsafeCStringAndConsume { pointer, count in
|
||||
for byte in UnsafeRawBufferPointer(start: pointer, count: count) {
|
||||
switch byte {
|
||||
case 0x22, 0x5C: output.append(contentsOf: [0x5C, byte])
|
||||
case 0..<0x20:
|
||||
let digits = Array("0123456789abcdef".utf8)
|
||||
output.append(contentsOf: [
|
||||
0x5C, 0x75, 0x30, 0x30, digits[Int(byte >> 4)], digits[Int(byte & 15)],
|
||||
])
|
||||
default: output.append(byte)
|
||||
}
|
||||
}
|
||||
}
|
||||
output.append(0x22)
|
||||
}
|
||||
// Reserve an upper bound before writing any plaintext, avoiding Data
|
||||
// reallocation while appending escaped secret bytes.
|
||||
output.reserveCapacity(64 + 6 * (privateKey.byteCount + (passphrase?.byteCount ?? 0)))
|
||||
output.append(contentsOf: "{\"privateKey\":".utf8)
|
||||
try append(privateKey)
|
||||
if let passphrase {
|
||||
output.append(contentsOf: ",\"passphrase\":".utf8)
|
||||
try append(passphrase)
|
||||
}
|
||||
output.append(0x7D)
|
||||
succeeded = true
|
||||
return output
|
||||
}
|
||||
|
||||
static func decodePrivateKeySecret(from data: inout Data) throws -> SSHSecret {
|
||||
let credential = try decodeKeySecrets(from: &data)
|
||||
guard case .software(let key, let passphrase) = credential else {
|
||||
throw KeychainSecretError.unexpectedValue
|
||||
}
|
||||
passphrase?.clear()
|
||||
return key
|
||||
}
|
||||
|
||||
static func decodeKeySecrets(
|
||||
from data: inout Data, observeSecret: ((SSHSecret) -> Void)? = nil
|
||||
) throws -> SSHResolvedKeyCredential {
|
||||
defer { SecretMemory.clear(&data) }
|
||||
var index = data.startIndex
|
||||
var privateKey: SSHSecret?
|
||||
var passphrase: SSHSecret?
|
||||
var succeeded = false
|
||||
defer {
|
||||
if !succeeded {
|
||||
privateKey?.clear()
|
||||
passphrase?.clear()
|
||||
}
|
||||
}
|
||||
func skipWhitespace() {
|
||||
while index < data.endIndex, [0x20, 0x09, 0x0A, 0x0D].contains(data[index]) { index += 1 }
|
||||
}
|
||||
func consume(_ expected: UInt8) throws {
|
||||
guard index < data.endIndex, data[index] == expected else { throw KeychainSecretError.unexpectedValue }
|
||||
index += 1
|
||||
}
|
||||
func hexUnit() throws -> UInt32 {
|
||||
var value: UInt32 = 0
|
||||
for _ in 0..<4 {
|
||||
guard index < data.endIndex else { throw KeychainSecretError.unexpectedValue }
|
||||
let byte = data[index]
|
||||
let digit: UInt8
|
||||
switch byte {
|
||||
case 0x30...0x39: digit = byte - 0x30
|
||||
case 0x41...0x46: digit = byte - 0x41 + 10
|
||||
case 0x61...0x66: digit = byte - 0x61 + 10
|
||||
default: throw KeychainSecretError.unexpectedValue
|
||||
}
|
||||
value = value * 16 + UInt32(digit)
|
||||
index += 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
func readString() throws -> SSHSecret {
|
||||
try consume(0x22)
|
||||
var bytes = Data()
|
||||
bytes.reserveCapacity(data.count)
|
||||
defer { SecretMemory.clear(&bytes) }
|
||||
while index < data.endIndex {
|
||||
let byte = data[index]
|
||||
index += 1
|
||||
if byte == 0x22 {
|
||||
func validateUTF8() throws {
|
||||
var decoder = Unicode.UTF8()
|
||||
var iterator = bytes.makeIterator()
|
||||
while true {
|
||||
switch decoder.decode(&iterator) {
|
||||
case .scalarValue: continue
|
||||
case .emptyInput: return
|
||||
case .error: throw KeychainSecretError.unexpectedValue
|
||||
}
|
||||
}
|
||||
}
|
||||
try validateUTF8()
|
||||
let secret = SSHSecret(consumingUTF8: &bytes)
|
||||
observeSecret?(secret)
|
||||
return secret
|
||||
}
|
||||
guard byte == 0x5C else {
|
||||
guard byte >= 0x20 else { throw KeychainSecretError.unexpectedValue }
|
||||
bytes.append(byte)
|
||||
continue
|
||||
}
|
||||
guard index < data.endIndex else { throw KeychainSecretError.unexpectedValue }
|
||||
let escape = data[index]
|
||||
index += 1
|
||||
switch escape {
|
||||
case 0x22, 0x5C, 0x2F: bytes.append(escape)
|
||||
case 0x62: bytes.append(0x08)
|
||||
case 0x66: bytes.append(0x0C)
|
||||
case 0x6E: bytes.append(0x0A)
|
||||
case 0x72: bytes.append(0x0D)
|
||||
case 0x74: bytes.append(0x09)
|
||||
case 0x75:
|
||||
var scalar = try hexUnit()
|
||||
if (0xD800...0xDBFF).contains(scalar) {
|
||||
try consume(0x5C)
|
||||
try consume(0x75)
|
||||
let low = try hexUnit()
|
||||
guard (0xDC00...0xDFFF).contains(low) else { throw KeychainSecretError.unexpectedValue }
|
||||
scalar = 0x10000 + ((scalar - 0xD800) << 10) + low - 0xDC00
|
||||
}
|
||||
guard let value = Unicode.Scalar(scalar) else { throw KeychainSecretError.unexpectedValue }
|
||||
Unicode.UTF8.encode(value) { bytes.append($0) }
|
||||
default: throw KeychainSecretError.unexpectedValue
|
||||
}
|
||||
}
|
||||
throw KeychainSecretError.unexpectedValue
|
||||
}
|
||||
skipWhitespace()
|
||||
try consume(0x7B)
|
||||
var sawPassphrase = false
|
||||
while true {
|
||||
skipWhitespace()
|
||||
let name = try readString()
|
||||
defer { name.clear() }
|
||||
skipWhitespace()
|
||||
try consume(0x3A)
|
||||
skipWhitespace()
|
||||
if name.matches("privateKey"), privateKey == nil {
|
||||
privateKey = try readString()
|
||||
} else if name.matches("passphrase"), !sawPassphrase {
|
||||
sawPassphrase = true
|
||||
if index < data.endIndex, data[index] == 0x6E {
|
||||
for byte in Array("null".utf8) { try consume(byte) }
|
||||
} else {
|
||||
passphrase = try readString()
|
||||
if passphrase?.byteCount == 0 {
|
||||
passphrase?.clear()
|
||||
passphrase = nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw KeychainSecretError.unexpectedValue
|
||||
}
|
||||
skipWhitespace()
|
||||
if index < data.endIndex, data[index] == 0x2C {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
try consume(0x7D)
|
||||
skipWhitespace()
|
||||
guard index == data.endIndex, let privateKey else { throw KeychainSecretError.unexpectedValue }
|
||||
succeeded = true
|
||||
return .software(privateKey, passphrase: passphrase)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,8 +29,17 @@ final class MobileKeyEditorModel: Identifiable {
|
||||
var cipher = SSHKeyMaterial.Cipher.aes256
|
||||
var rounds = 100
|
||||
var confirmation = ""
|
||||
var privateKey = ""
|
||||
var privateKey = "" {
|
||||
didSet {
|
||||
if privateKey != originalPrivateKey, oldValue == originalPrivateKey, !originalPassphrase.isEmpty {
|
||||
passphrase = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
var passphrase = ""
|
||||
var savePassphrase = true
|
||||
private var originalPassphrase = ""
|
||||
private var originalSavePassphrase = true
|
||||
var certificate: String
|
||||
var requiresUserPresence = false
|
||||
private(set) var isLoadingPrivateKey: Bool
|
||||
@@ -49,6 +58,8 @@ final class MobileKeyEditorModel: Identifiable {
|
||||
self.mode = mode
|
||||
self.credentials = credentials
|
||||
original = key
|
||||
savePassphrase = mode != .edit
|
||||
originalSavePassphrase = mode != .edit
|
||||
label = key?.label ?? ""
|
||||
storage = key?.storage ?? defaultStorage
|
||||
certificate = key?.certificate ?? ""
|
||||
@@ -76,7 +87,9 @@ final class MobileKeyEditorModel: Identifiable {
|
||||
|
||||
var isDirty: Bool {
|
||||
label != (original?.label ?? "") || storage != (original?.storage ?? .iCloud)
|
||||
|| certificate != (original?.certificate ?? "") || privateKey != originalPrivateKey || !passphrase.isEmpty
|
||||
|| certificate != (original?.certificate ?? "") || privateKey != originalPrivateKey
|
||||
|| passphrase != originalPassphrase
|
||||
|| savePassphrase != originalSavePassphrase
|
||||
|| algorithm != .ed25519 || requiresUserPresence || colorTag != (original?.colorTag ?? .none)
|
||||
|| curveBits != 256 || rsaBits != 3072 || cipher != .aes256 || rounds != 100 || !confirmation.isEmpty
|
||||
}
|
||||
@@ -154,6 +167,10 @@ final class MobileKeyEditorModel: Identifiable {
|
||||
}
|
||||
privateKey = secret.privateKey
|
||||
originalPrivateKey = secret.privateKey
|
||||
passphrase = secret.passphrase ?? ""
|
||||
originalPassphrase = passphrase
|
||||
savePassphrase = !passphrase.isEmpty
|
||||
originalSavePassphrase = savePassphrase
|
||||
didLoadPrivateKey = true
|
||||
} catch {
|
||||
privateKeyLoadError = error.localizedDescription
|
||||
@@ -259,7 +276,9 @@ final class MobileKeyEditorModel: Identifiable {
|
||||
key.keySize = material.keySize
|
||||
key.hasPassphrase = enteredPassphrase != nil || (keepsOriginalPrivateKey && key.hasPassphrase)
|
||||
try credentials.upsert(
|
||||
key, credential: .software(SSHPrivateKeySecret(privateKey: input))
|
||||
key,
|
||||
credential: .software(
|
||||
SSHPrivateKeySecret(privateKey: input, passphrase: savePassphrase ? enteredPassphrase : nil))
|
||||
)
|
||||
clearSensitiveDrafts()
|
||||
return
|
||||
@@ -304,17 +323,20 @@ final class MobileKeyEditorModel: Identifiable {
|
||||
certificate: normalizedCertificate, fingerprint: material.fingerprint,
|
||||
credentialRef: "termind.key.\(UUID())", storage: storage, keySize: material.keySize,
|
||||
hasPassphrase: passphrase != nil, createdAt: .now, colorTag: colorTag)
|
||||
secret = .software(SSHPrivateKeySecret(privateKey: material.privateKey))
|
||||
secret = .software(
|
||||
SSHPrivateKeySecret(privateKey: material.privateKey, passphrase: savePassphrase ? passphrase : nil))
|
||||
}
|
||||
try credentials.upsert(key, credential: secret)
|
||||
clearSensitiveDrafts()
|
||||
}
|
||||
|
||||
private func clearSensitiveDrafts() {
|
||||
func clearSensitiveDrafts() {
|
||||
didLoadPrivateKey = mode != .edit
|
||||
privateKey = ""
|
||||
originalPrivateKey = ""
|
||||
passphrase = ""
|
||||
confirmation = ""
|
||||
originalPassphrase = ""
|
||||
inspectedMaterial = nil
|
||||
}
|
||||
}
|
||||
@@ -193,7 +193,10 @@ private struct MobileKeyCredentialCard: View {
|
||||
@ViewBuilder private var primaryChips: some View {
|
||||
MobileCardChip(text: keyDisplayName)
|
||||
if key.hasPassphrase {
|
||||
MobileCardChip(title: AppStrings.Keychain.passphraseRequired, symbol: "lock.fill")
|
||||
MobileCardChip(
|
||||
title: credentials.hasSavedPassphrase(for: key)
|
||||
? AppStrings.Keychain.passphraseSaved : AppStrings.Keychain.passphraseRequired,
|
||||
symbol: "lock.fill")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ struct MobileKeyEditor: View {
|
||||
RevealableSecretField(AppStrings.Keychain.confirmPassphrase, text: $model.confirmation)
|
||||
.multilineTextAlignment(.trailing).disabled(model.passphrase.isEmpty)
|
||||
}
|
||||
Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $model.savePassphrase)
|
||||
.disabled(model.passphrase.isEmpty)
|
||||
if !model.passphrase.isEmpty {
|
||||
Picker(AppStrings.Keychain.cipher.swiftUIKey, selection: $model.cipher) {
|
||||
ForEach(SSHKeyMaterial.Cipher.allCases) { Text($0.displayName).tag($0) }
|
||||
@@ -106,6 +108,8 @@ struct MobileKeyEditor: View {
|
||||
RevealableSecretField(AppStrings.Keychain.passphrase, text: $model.passphrase)
|
||||
.multilineTextAlignment(.leading)
|
||||
.disabled(!model.canEditPrivateKey)
|
||||
Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $model.savePassphrase)
|
||||
.disabled(!model.canEditPrivateKey || model.passphrase.isEmpty)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -159,6 +163,7 @@ struct MobileKeyEditor: View {
|
||||
} message: {
|
||||
Text(fileError ?? "")
|
||||
}
|
||||
.onDisappear { model.clearSensitiveDrafts() }
|
||||
.task { await model.loadPrivateKey() }
|
||||
.task(id: model.validationToken) { await model.derivePublicMaterial() }
|
||||
}
|
||||
|
||||
@@ -387,10 +387,15 @@ final class MobileWorkspace: Identifiable {
|
||||
connect()
|
||||
return
|
||||
}
|
||||
if authenticationPrompt?.requiresPassphrase == true, var staged = submissions[host.id] {
|
||||
guard let passphrase, !passphrase.isEmpty else { return }
|
||||
staged.passphrase = passphrase
|
||||
submissions[host.id] = staged
|
||||
if let prompt = authenticationPrompt, prompt.requiresPassphrase {
|
||||
do {
|
||||
submissions[host.id] = try hosts.passphraseSubmission(
|
||||
for: prompt, existing: submissions[host.id], passphrase: passphrase, save: save)
|
||||
} catch {
|
||||
connectionFailure = error.localizedDescription
|
||||
authenticationPrompt?.failureMessage = error.localizedDescription
|
||||
return
|
||||
}
|
||||
} else {
|
||||
let chosenKey = identity?.keyID ?? keyID
|
||||
if save {
|
||||
@@ -684,7 +689,7 @@ final class MobileWorkspace: Identifiable {
|
||||
return
|
||||
}
|
||||
if let passphraseHost = submissions.firstHostRequiringPassphrase(
|
||||
in: route, key: hosts.key
|
||||
in: route, key: hosts.key, requiresEntry: hosts.requiresPassphraseEntry
|
||||
) {
|
||||
authenticationRequired = true
|
||||
authenticationMode = .localIdentity
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
import UIKit
|
||||
|
||||
@testable import TermindIOS
|
||||
|
||||
@Suite(.serialized) @MainActor
|
||||
struct MobilePassphraseFormLayoutTests {
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [320.0, 600.0])
|
||||
func passphraseActionsFitCompactAndRegularWidths(language: String, width: Double) throws {
|
||||
let host = TermindDomain.Host(name: "Production", host: "server.example.invalid")
|
||||
let prompt = SSHAuthenticationPrompt(host: host, username: "deploy", requiresPassphrase: true)
|
||||
let controller = UIHostingController(
|
||||
rootView: SSHAuthenticationForm(
|
||||
prompt: prompt, submit: { _, _, _, _, _, _ in }, cancel: {}, library: HostLibraryModel()
|
||||
).environment(\.locale, Locale(identifier: language)).frame(width: width))
|
||||
let size = controller.sizeThatFits(in: CGSize(width: width, height: 1000))
|
||||
#expect(abs(size.width - width) < 1)
|
||||
#expect(size.height > 100 && size.height < 500)
|
||||
controller.view.frame = CGRect(origin: .zero, size: size)
|
||||
controller.view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
@Test(
|
||||
.enabled(if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1"),
|
||||
arguments: ["en", "zh-Hans"], [false, true])
|
||||
func rendersPassphraseActionsInANativeWindow(language: String, showKeys: Bool) async throws {
|
||||
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
||||
let previous = scene.keyWindow
|
||||
let host = TermindDomain.Host(name: "Production", host: "server.example.invalid")
|
||||
let prompt = SSHAuthenticationPrompt(host: host, username: "deploy", requiresPassphrase: true)
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
for saved in [false, true] {
|
||||
let key = SSHKeyRef(
|
||||
label: saved ? "Saved Key" : "Unsaved Key", publicKey: "ssh-ed25519 fixture",
|
||||
credentialRef: "termind.key.\(UUID())", hasPassphrase: true)
|
||||
try app.credentials.upsert(
|
||||
key, credential: .software(.init(privateKey: "fixture", passphrase: saved ? "fixture-phrase" : nil)))
|
||||
}
|
||||
let controller = UIHostingController(
|
||||
rootView: Group {
|
||||
if showKeys {
|
||||
NavigationStack { MobileCredentialsView(app: app) }
|
||||
} else {
|
||||
SSHAuthenticationForm(
|
||||
prompt: prompt, submit: { _, _, _, _, _, _ in }, cancel: {}, library: app.library
|
||||
).frame(width: 320).padding()
|
||||
}
|
||||
}.environment(\.locale, Locale(identifier: language)))
|
||||
let window = UIWindow(windowScene: scene)
|
||||
window.rootViewController = controller
|
||||
window.makeKeyAndVisible()
|
||||
defer {
|
||||
window.isHidden = true
|
||||
window.rootViewController = nil
|
||||
previous?.makeKey()
|
||||
}
|
||||
controller.view.layoutIfNeeded()
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
|
||||
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
|
||||
}
|
||||
let data = try #require(image.pngData())
|
||||
#expect(data.count > 10000)
|
||||
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(
|
||||
"termind-passphrase-ios-\(language)-\(showKeys ? "keys" : "form").png")
|
||||
try data.write(to: url)
|
||||
print("Passphrase preview: \(url.path)")
|
||||
}
|
||||
}
|
||||
@@ -379,6 +379,72 @@ struct MobileVaultFeatureTests {
|
||||
app.removeScene(sceneID)
|
||||
}
|
||||
|
||||
@Test func savedPassphraseDefaultsAndEditingPreserveOrRemoveTheSavedValue() async throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
|
||||
#expect(generator.savePassphrase)
|
||||
generator.passphrase = "test-only-passphrase"
|
||||
generator.confirmation = generator.passphrase
|
||||
try await generator.save()
|
||||
let original = try #require(app.credentials.keys.first)
|
||||
let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: original)
|
||||
await editor.loadPrivateKey()
|
||||
#expect(editor.savePassphrase && !editor.isDirty)
|
||||
#expect(editor.passphrase == "test-only-passphrase")
|
||||
editor.label = "Renamed"
|
||||
try await editor.save()
|
||||
guard case .software(let saved)? = try app.credentials.keyCredential(for: original) else {
|
||||
Issue.record("Expected saved key")
|
||||
return
|
||||
}
|
||||
#expect(saved.passphrase == "test-only-passphrase")
|
||||
let updated = try #require(app.credentials.key(original.id))
|
||||
let remover = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: updated)
|
||||
await remover.loadPrivateKey()
|
||||
remover.savePassphrase = false
|
||||
#expect(remover.isDirty)
|
||||
try await remover.save()
|
||||
guard case .software(let removed)? = try app.credentials.keyCredential(for: updated) else {
|
||||
Issue.record("Expected saved key")
|
||||
return
|
||||
}
|
||||
#expect(removed.passphrase == nil)
|
||||
#expect(removed.privateKey == saved.privateKey)
|
||||
#expect(app.credentials.key(original.id)?.hasPassphrase == true)
|
||||
let importer = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials)
|
||||
#expect(importer.savePassphrase)
|
||||
importer.privateKey = saved.privateKey
|
||||
importer.passphrase = "test-only-passphrase"
|
||||
importer.savePassphrase = false
|
||||
try await importer.save()
|
||||
let imported = try #require(app.credentials.keys.first { $0.id != original.id })
|
||||
guard case .software(let importedSecret)? = try app.credentials.keyCredential(for: imported) else {
|
||||
Issue.record("Expected imported key")
|
||||
return
|
||||
}
|
||||
#expect(importedSecret.passphrase == nil)
|
||||
}
|
||||
|
||||
@Test func replacingPrivateKeyDoesNotReuseItsSavedPassphrase() async throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
|
||||
generator.passphrase = "old-passphrase"
|
||||
generator.confirmation = generator.passphrase
|
||||
try await generator.save()
|
||||
let key = try #require(app.credentials.keys.first)
|
||||
let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: key)
|
||||
await editor.loadPrivateKey()
|
||||
editor.privateKey = try SSHKeyMaterial.generate(.ed25519, comment: "replacement", passphrase: nil).privateKey
|
||||
#expect(editor.passphrase.isEmpty)
|
||||
try await editor.save()
|
||||
guard case .software(let secret)? = try app.credentials.keyCredential(for: key) else {
|
||||
Issue.record("Expected replacement key")
|
||||
return
|
||||
}
|
||||
#expect(secret.passphrase == nil)
|
||||
#expect(app.credentials.key(key.id)?.hasPassphrase == false)
|
||||
}
|
||||
|
||||
@Test func encryptedKeyGenerationImportAndMetadataEditingKeepTheSecretOutOfRecords() async throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
|
||||
|
||||
@@ -437,7 +437,7 @@ struct KeychainImportDetail: View {
|
||||
|
||||
private func save(
|
||||
name: String, colorTag: TagColor, storage: KeyStorage, privateKey: String,
|
||||
passphrase: String?, certificate: String, material: SSHKeyMaterial
|
||||
passphrase: String?, savePassphrase: Bool, certificate: String, material: SSHKeyMaterial
|
||||
) async throws -> SSHKeyRef {
|
||||
let key = SSHKeyRef(
|
||||
label: name,
|
||||
@@ -450,7 +450,7 @@ struct KeychainImportDetail: View {
|
||||
try app.credentials.upsert(
|
||||
key,
|
||||
credential: .software(
|
||||
SSHPrivateKeySecret(privateKey: privateKey)
|
||||
SSHPrivateKeySecret(privateKey: privateKey, passphrase: savePassphrase ? passphrase : nil)
|
||||
)
|
||||
)
|
||||
window.selectVaultItem(.key(key.id), on: .keychain)
|
||||
@@ -468,11 +468,11 @@ private struct SoftwareKeyEditor: View {
|
||||
let title: LocalizedStringResource
|
||||
let symbol: String
|
||||
let close: () -> Void
|
||||
let loadPrivateKey: (() throws -> String)?
|
||||
let loadPrivateKey: (() throws -> SSHPrivateKeySecret)?
|
||||
let save:
|
||||
(
|
||||
_ name: String, _ colorTag: TagColor, _ storage: KeyStorage, _ privateKey: String,
|
||||
_ passphrase: String?, _ certificate: String, _ material: SSHKeyMaterial
|
||||
_ passphrase: String?, _ savePassphrase: Bool, _ certificate: String, _ material: SSHKeyMaterial
|
||||
) async throws -> SSHKeyRef
|
||||
let saveMetadata: ((_ name: String, _ colorTag: TagColor, _ certificate: String) throws -> SSHKeyRef)?
|
||||
let delete: (() throws -> Void)?
|
||||
@@ -483,6 +483,9 @@ private struct SoftwareKeyEditor: View {
|
||||
@State private var privateKey = ""
|
||||
@State private var originalPrivateKey = ""
|
||||
@State private var passphrase = ""
|
||||
@State private var originalPassphrase = ""
|
||||
@State private var savePassphrase = true
|
||||
@State private var originalSavePassphrase = true
|
||||
@State private var certificate: String
|
||||
@State private var material: SSHKeyMaterial?
|
||||
@State private var isLoading: Bool
|
||||
@@ -502,12 +505,12 @@ private struct SoftwareKeyEditor: View {
|
||||
existing: SSHKeyRef? = nil, title: LocalizedStringResource,
|
||||
symbol: String, close: @escaping () -> Void,
|
||||
defaultStorage: KeyStorage = .iCloud,
|
||||
loadPrivateKey: (() throws -> String)? = nil,
|
||||
loadPrivateKey: (() throws -> SSHPrivateKeySecret)? = nil,
|
||||
saveMetadata: ((_ name: String, _ colorTag: TagColor, _ certificate: String) throws -> SSHKeyRef)? = nil,
|
||||
delete: (() throws -> Void)? = nil,
|
||||
save:
|
||||
@escaping (
|
||||
String, TagColor, KeyStorage, String, String?, String, SSHKeyMaterial
|
||||
String, TagColor, KeyStorage, String, String?, Bool, String, SSHKeyMaterial
|
||||
) async throws -> SSHKeyRef
|
||||
) {
|
||||
self.existing = existing
|
||||
@@ -579,6 +582,8 @@ private struct SoftwareKeyEditor: View {
|
||||
prompt: Text(AppStrings.Common.optional)
|
||||
)
|
||||
.disabled(!canEditPrivateKey)
|
||||
Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $savePassphrase)
|
||||
.disabled(!canEditPrivateKey || passphrase.isEmpty)
|
||||
} header: {
|
||||
fileSectionHeader(AppStrings.Keychain.privateKey, canChoose: canEditPrivateKey) {
|
||||
chooseFile(for: .privateKey)
|
||||
@@ -661,7 +666,18 @@ private struct SoftwareKeyEditor: View {
|
||||
await commitChanges()
|
||||
}
|
||||
}
|
||||
.onChange(of: privateKey) { old, value in
|
||||
if value != originalPrivateKey, old == originalPrivateKey, !originalPassphrase.isEmpty {
|
||||
passphrase = ""
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
privateKey = ""
|
||||
originalPrivateKey = ""
|
||||
passphrase = ""
|
||||
originalPassphrase = ""
|
||||
material = nil
|
||||
isLoading = loadPrivateKey != nil
|
||||
guard let editingToken else { return }
|
||||
editingSession.unregister(editingToken)
|
||||
}
|
||||
@@ -735,7 +751,9 @@ private struct SoftwareKeyEditor: View {
|
||||
private var isDirty: Bool {
|
||||
guard let existing else { return !name.isEmpty || !privateKey.isEmpty || !certificate.isEmpty }
|
||||
return name != existing.label || colorTag != existing.colorTag || storage != existing.storage
|
||||
|| privateKey != originalPrivateKey || !passphrase.isEmpty || certificate != existing.certificate
|
||||
|| privateKey != originalPrivateKey || passphrase != originalPassphrase
|
||||
|| savePassphrase != originalSavePassphrase
|
||||
|| certificate != existing.certificate
|
||||
}
|
||||
|
||||
private func cancelEditing() {
|
||||
@@ -786,7 +804,7 @@ private struct SoftwareKeyEditor: View {
|
||||
}
|
||||
_ = try await save(
|
||||
name.trimmingCharacters(in: .whitespacesAndNewlines), colorTag, storage,
|
||||
privateKey, passphrase, normalizedCertificate, inspected
|
||||
privateKey, passphrase, savePassphrase, normalizedCertificate, inspected
|
||||
)
|
||||
isWorking = false
|
||||
return true
|
||||
@@ -845,8 +863,13 @@ private struct SoftwareKeyEditor: View {
|
||||
private func load() {
|
||||
guard isLoading else { return }
|
||||
do {
|
||||
privateKey = try loadPrivateKey?() ?? ""
|
||||
let secret = try loadPrivateKey?()
|
||||
privateKey = secret?.privateKey ?? ""
|
||||
originalPrivateKey = privateKey
|
||||
passphrase = secret?.passphrase ?? ""
|
||||
originalPassphrase = passphrase
|
||||
savePassphrase = !passphrase.isEmpty
|
||||
originalSavePassphrase = savePassphrase
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
canEditPrivateKey = false
|
||||
@@ -942,6 +965,7 @@ struct KeychainGenerateDetail: View {
|
||||
|
||||
private func generate(
|
||||
label: String, colorTag: TagColor, generation: SSHKeyMaterial.Generation, passphrase: String?,
|
||||
savePassphrase: Bool,
|
||||
cipher: SSHKeyMaterial.Cipher, rounds: Int, storage: KeyStorage,
|
||||
requiresUserPresence: Bool
|
||||
) async throws -> SSHKeyRef {
|
||||
@@ -979,7 +1003,7 @@ struct KeychainGenerateDetail: View {
|
||||
try app.credentials.upsert(
|
||||
key,
|
||||
credential: .software(
|
||||
SSHPrivateKeySecret(privateKey: material.privateKey)
|
||||
SSHPrivateKeySecret(privateKey: material.privateKey, passphrase: savePassphrase ? passphrase : nil)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -999,7 +1023,7 @@ private struct GenerateKeySheet: View {
|
||||
|
||||
let generate:
|
||||
(
|
||||
String, TagColor, SSHKeyMaterial.Generation, String?, SSHKeyMaterial.Cipher, Int,
|
||||
String, TagColor, SSHKeyMaterial.Generation, String?, Bool, SSHKeyMaterial.Cipher, Int,
|
||||
KeyStorage, Bool
|
||||
) async throws -> SSHKeyRef
|
||||
let close: () -> Void
|
||||
@@ -1011,6 +1035,7 @@ private struct GenerateKeySheet: View {
|
||||
@State private var curveBits = 256
|
||||
@State private var rsaBits = 3072
|
||||
@State private var passphrase = ""
|
||||
@State private var savePassphrase = true
|
||||
@State private var confirmation = ""
|
||||
@State private var cipher = SSHKeyMaterial.Cipher.aes256
|
||||
@State private var rounds = 100
|
||||
@@ -1028,7 +1053,7 @@ private struct GenerateKeySheet: View {
|
||||
defaultStorage: KeyStorage = .iCloud,
|
||||
generate:
|
||||
@escaping (
|
||||
String, TagColor, SSHKeyMaterial.Generation, String?, SSHKeyMaterial.Cipher, Int,
|
||||
String, TagColor, SSHKeyMaterial.Generation, String?, Bool, SSHKeyMaterial.Cipher, Int,
|
||||
KeyStorage, Bool
|
||||
) async throws -> SSHKeyRef,
|
||||
close: @escaping () -> Void
|
||||
@@ -1097,6 +1122,8 @@ private struct GenerateKeySheet: View {
|
||||
prompt: Text(AppStrings.Common.optional))
|
||||
RevealableSecretField(AppStrings.Keychain.confirmPassphrase, text: $confirmation)
|
||||
.disabled(passphrase.isEmpty)
|
||||
Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $savePassphrase)
|
||||
.disabled(passphrase.isEmpty)
|
||||
if !passphrase.isEmpty {
|
||||
Picker(AppStrings.Keychain.cipher.swiftUIKey, selection: $cipher) {
|
||||
ForEach(SSHKeyMaterial.Cipher.allCases) { cipher in
|
||||
@@ -1136,6 +1163,10 @@ private struct GenerateKeySheet: View {
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onDisappear {
|
||||
passphrase = ""
|
||||
confirmation = ""
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -1146,9 +1177,11 @@ private struct GenerateKeySheet: View {
|
||||
_ = try await generate(
|
||||
label.trimmingCharacters(in: .whitespacesAndNewlines), colorTag, generation,
|
||||
storage == .secureEnclave || passphrase.isEmpty ? nil : passphrase,
|
||||
cipher, rounds, storage,
|
||||
savePassphrase, cipher, rounds, storage,
|
||||
storage == .secureEnclave && requiresUserPresence
|
||||
)
|
||||
passphrase = ""
|
||||
confirmation = ""
|
||||
close()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
@@ -1196,10 +1229,10 @@ struct KeychainEditDetail: View {
|
||||
},
|
||||
delete: { try delete(key) },
|
||||
save: {
|
||||
name, colorTag, storage, privateKey, passphrase, certificate, material in
|
||||
name, colorTag, storage, privateKey, passphrase, savePassphrase, certificate, material in
|
||||
try await saveSoftwareKey(
|
||||
key, name: name, colorTag: colorTag, storage: storage,
|
||||
privateKey: privateKey, passphrase: passphrase,
|
||||
privateKey: privateKey, passphrase: passphrase, savePassphrase: savePassphrase,
|
||||
certificate: certificate, material: material
|
||||
)
|
||||
}
|
||||
@@ -1210,13 +1243,13 @@ struct KeychainEditDetail: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPrivateKey(_ key: SSHKeyRef) throws -> String {
|
||||
private func loadPrivateKey(_ key: SSHKeyRef) throws -> SSHPrivateKeySecret {
|
||||
guard case .software(let secret)? = try app.credentials.keyCredential(for: key) else {
|
||||
throw TermindSSHError.library(
|
||||
String(localized: AppStrings.Keychain.thePrivateKeyIsUnavailableOnThisMac)
|
||||
)
|
||||
}
|
||||
return secret.privateKey
|
||||
return secret
|
||||
}
|
||||
|
||||
private func delete(_ key: SSHKeyRef) throws {
|
||||
@@ -1226,7 +1259,7 @@ struct KeychainEditDetail: View {
|
||||
|
||||
private func saveSoftwareKey(
|
||||
_ original: SSHKeyRef, name: String, colorTag: TagColor, storage: KeyStorage,
|
||||
privateKey: String, passphrase: String?, certificate: String,
|
||||
privateKey: String, passphrase: String?, savePassphrase: Bool, certificate: String,
|
||||
material: SSHKeyMaterial
|
||||
) async throws -> SSHKeyRef {
|
||||
var updated = original
|
||||
@@ -1238,12 +1271,12 @@ struct KeychainEditDetail: View {
|
||||
updated.certificate = certificate
|
||||
updated.fingerprint = material.fingerprint
|
||||
updated.keySize = material.keySize
|
||||
let keepsOriginalPrivateKey = privateKey == (try loadPrivateKey(original))
|
||||
let keepsOriginalPrivateKey = privateKey == (try loadPrivateKey(original)).privateKey
|
||||
updated.hasPassphrase = passphrase != nil || (keepsOriginalPrivateKey && original.hasPassphrase)
|
||||
try app.credentials.upsert(
|
||||
updated,
|
||||
credential: .software(
|
||||
SSHPrivateKeySecret(privateKey: privateKey)
|
||||
SSHPrivateKeySecret(privateKey: privateKey, passphrase: savePassphrase ? passphrase : nil)
|
||||
)
|
||||
)
|
||||
window.selectVaultItem(.key(updated.id), on: .keychain)
|
||||
@@ -1821,7 +1854,9 @@ private struct KeyCard: View {
|
||||
CardChip(text: keyDisplayName)
|
||||
if key.hasPassphrase {
|
||||
CardChip(
|
||||
text: String(localized: AppStrings.Keychain.passphraseRequired),
|
||||
text: String(
|
||||
localized: app.credentials.hasSavedPassphrase(for: key)
|
||||
? AppStrings.Keychain.passphraseSaved : AppStrings.Keychain.passphraseRequired),
|
||||
symbol: "lock.fill"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -353,7 +353,7 @@ final class SFTPChannelModel: Identifiable {
|
||||
return
|
||||
}
|
||||
if let passphraseHost = suppliedAuthentications.firstHostRequiringPassphrase(
|
||||
in: route, key: store.key
|
||||
in: route, key: store.key, requiresEntry: store.requiresPassphraseEntry
|
||||
) {
|
||||
requestAuthentication(
|
||||
host: passphraseHost, store: store,
|
||||
@@ -732,15 +732,18 @@ final class SFTPChannelModel: Identifiable {
|
||||
return
|
||||
}
|
||||
|
||||
if prompt.requiresPassphrase,
|
||||
var staged = suppliedAuthentications[host.id]
|
||||
{
|
||||
guard let passphrase, !passphrase.isEmpty else { return }
|
||||
staged.passphrase = passphrase
|
||||
suppliedAuthentications[host.id] = staged
|
||||
pendingAuthentication = nil
|
||||
connectionJourney.resume()
|
||||
connect(using: store)
|
||||
if prompt.requiresPassphrase {
|
||||
do {
|
||||
suppliedAuthentications[host.id] = try store.passphraseSubmission(
|
||||
for: prompt, existing: suppliedAuthentications[host.id], passphrase: passphrase, save: save)
|
||||
pendingAuthentication = nil
|
||||
connectionJourney.resume()
|
||||
connect(using: store)
|
||||
} catch {
|
||||
var failed = prompt
|
||||
failed.failureMessage = error.localizedDescription
|
||||
pendingAuthentication = failed
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1171,7 +1174,7 @@ final class SFTPChannelModel: Identifiable {
|
||||
let needsPassphrase =
|
||||
requiresPassphrase
|
||||
?? (!username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& selectedKeyID.flatMap { store.key($0) }?.requiresPassphraseEntry == true)
|
||||
&& selectedKeyID.flatMap { store.key($0) }.map { store.requiresPassphraseEntry(for: $0) } == true)
|
||||
pendingAuthentication = SSHAuthenticationPromptResolver.host(
|
||||
host, using: store, submission: submission, username: username,
|
||||
selectedKeyID: selectedKeyID,
|
||||
|
||||
@@ -420,7 +420,7 @@ final class SSHSessionModel: Identifiable {
|
||||
return
|
||||
}
|
||||
if let passphraseHost = suppliedAuthentications.firstHostRequiringPassphrase(
|
||||
in: route, key: store.key
|
||||
in: route, key: store.key, requiresEntry: store.requiresPassphraseEntry
|
||||
), let submission = suppliedAuthentications[passphraseHost.id] {
|
||||
requestAuthentication(
|
||||
for: passphraseHost, username: submission.username,
|
||||
@@ -645,8 +645,7 @@ final class SSHSessionModel: Identifiable {
|
||||
let retryNeedsPassphrase =
|
||||
submitted?.requiresPasswordReentry != true
|
||||
&& submitted?.hasPassphrase != true
|
||||
&& authenticationKeyID.flatMap { store.key($0) }?
|
||||
.requiresPassphraseEntry == true
|
||||
&& authenticationKeyID.flatMap { store.key($0) }?.hasPassphrase == true
|
||||
let returnsToIdentity = submitted?.requiresPassphraseReentry == true
|
||||
requestAuthentication(
|
||||
for: authenticationHost,
|
||||
@@ -703,15 +702,18 @@ final class SSHSessionModel: Identifiable {
|
||||
return
|
||||
}
|
||||
|
||||
if prompt.requiresPassphrase,
|
||||
var staged = suppliedAuthentications[host.id]
|
||||
{
|
||||
guard let passphrase, !passphrase.isEmpty else { return }
|
||||
staged.passphrase = passphrase
|
||||
suppliedAuthentications[host.id] = staged
|
||||
pendingAuthentication = nil
|
||||
connectionJourney.resume()
|
||||
connect(using: store)
|
||||
if prompt.requiresPassphrase {
|
||||
do {
|
||||
suppliedAuthentications[host.id] = try store.passphraseSubmission(
|
||||
for: prompt, existing: suppliedAuthentications[host.id], passphrase: passphrase, save: save)
|
||||
pendingAuthentication = nil
|
||||
connectionJourney.resume()
|
||||
connect(using: store)
|
||||
} catch {
|
||||
var failed = prompt
|
||||
failed.failureMessage = error.localizedDescription
|
||||
pendingAuthentication = failed
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -745,7 +747,7 @@ final class SSHSessionModel: Identifiable {
|
||||
for: host, username: trimmedUsername,
|
||||
selectedKeyID: submittedKeyID, failureMessage: error.localizedDescription,
|
||||
requiresPassphrase:
|
||||
submittedKeyID.flatMap { store.key($0) }?.requiresPassphraseEntry == true
|
||||
submittedKeyID.flatMap { store.key($0) }.map { store.requiresPassphraseEntry(for: $0) } == true
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -255,8 +255,8 @@ struct AgentHostAuthenticationTests {
|
||||
#expect(configuration.username == "deploy")
|
||||
}
|
||||
|
||||
@Test
|
||||
func passwordAndProtectedKeyPassphraseAreBothRetainedForTheConnection() async throws {
|
||||
@Test(arguments: [false, true])
|
||||
func passwordAndProtectedKeyPassphraseAreBothRetainedForTheConnection(save: Bool) async throws {
|
||||
let keychain = MemoryHostSecretStore()
|
||||
let reference = "termind.key.\(UUID().uuidString)"
|
||||
let key = SSHKeyRef(
|
||||
@@ -291,7 +291,7 @@ struct AgentHostAuthenticationTests {
|
||||
return .submit(
|
||||
AgentSSHAuthenticationAnswer(
|
||||
username: request.username, passphrase: "one-use",
|
||||
keyID: key.id, save: false
|
||||
keyID: key.id, save: save
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -323,6 +323,10 @@ struct AgentHostAuthenticationTests {
|
||||
return
|
||||
}
|
||||
#expect(stored.privateKey == "encrypted")
|
||||
#expect(stored.passphrase == (save ? "one-use" : nil))
|
||||
#expect(store.requiresPassphraseEntry(for: key) == !save)
|
||||
#expect(store.host(host.id)?.identityID == nil)
|
||||
configuration.clearSecrets()
|
||||
#expect(store.key(key.id)?.requiresPassphraseEntry == true)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import TermindApplication
|
||||
@testable import TermindRemoteInfrastructure
|
||||
|
||||
struct KeyPassphraseEnvelopeTests {
|
||||
@Test(arguments: [
|
||||
#"{"privateKey":"encrypted","passphrase":"密语😀\n\"\\"}"#,
|
||||
#"{"passphrase":"\u5bc6\u8bed\ud83d\ude00\n\"\\","privateKey":"encrypted"}"#,
|
||||
])
|
||||
func oldEnvelopeDecodesWithoutImmutableSecretStrings(_ json: String) throws {
|
||||
var data = Data(json.utf8)
|
||||
let observation = EnvelopeZeroization()
|
||||
let credential = try KeychainSecretStore.decodeKeySecrets(from: &data)
|
||||
guard case .software(let key, let passphrase) = credential else {
|
||||
Issue.record("Expected software key")
|
||||
return
|
||||
}
|
||||
let phrase = try #require(passphrase)
|
||||
for secret in [key, phrase] { secret.observeZeroization { observation.record($0) } }
|
||||
#expect(key.matches("encrypted"))
|
||||
#expect(phrase.matches("密语😀\n\"\\"))
|
||||
#expect(data.isEmpty)
|
||||
var encoded = try KeychainSecretStore.encodeKeySecrets(privateKey: key, passphrase: phrase)
|
||||
let editor = try JSONDecoder().decode(SSHPrivateKeySecret.self, from: encoded)
|
||||
#expect(editor.privateKey == "encrypted" && editor.passphrase == "密语😀\n\"\\")
|
||||
SecretMemory.clear(&encoded)
|
||||
#expect(observation.count == 2 && observation.allZero)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
#"{"privateKey":"key"}"#, #"{"privateKey":"key","passphrase":null}"#, #"{"passphrase":"","privateKey":"key"}"#,
|
||||
])
|
||||
func envelopesWithoutSavedPassphraseRemainCompatible(_ json: String) throws {
|
||||
var data = Data(json.utf8)
|
||||
let credential = try KeychainSecretStore.decodeKeySecrets(from: &data)
|
||||
defer { credential.clearSecrets() }
|
||||
#expect(!credential.hasSavedPassphrase)
|
||||
#expect(data.isEmpty)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
#"{"privateKey":"key","privateKey":"duplicate"}"#,
|
||||
#"{"privateKey":"key","passphrase":"first","passphrase":"second"}"#,
|
||||
#"{"privateKey":"key","passphrase":12}"#,
|
||||
#"{"privateKey":"key","passphrase":"\ud800"}"#,
|
||||
#"{"privateKey":"key","passphrase":"saved"} trailing"#,
|
||||
#"{"passphrase":"saved"}"#,
|
||||
#"{"privateKey":"key","extra":"saved"}"#,
|
||||
#"{"privateKey":"key","passphrase":"saved",}"#,
|
||||
])
|
||||
func malformedEnvelopeZeroesEveryDecodedBuffer(_ json: String) {
|
||||
let observation = EnvelopeZeroization()
|
||||
var data = Data(json.utf8)
|
||||
#expect(throws: KeychainSecretError.self) {
|
||||
_ = try KeychainSecretStore.decodeKeySecrets(from: &data) { secret in
|
||||
secret.observeZeroization { observation.record($0) }
|
||||
}
|
||||
}
|
||||
#expect(data.isEmpty)
|
||||
#expect(observation.count > 0 && observation.allZero)
|
||||
}
|
||||
|
||||
@Test func keychainSourceBytesAreZeroedOnParseFailure() {
|
||||
let observation = EnvelopeZeroization()
|
||||
let bytes = Array(
|
||||
(#"{"privateKey":"key","passphrase":"saved"} invalid"# + String(repeating: " ", count: 4096)).utf8)
|
||||
let pointer = UnsafeMutableRawPointer.allocate(byteCount: bytes.count, alignment: 1)
|
||||
bytes.withUnsafeBytes { pointer.copyMemory(from: $0.baseAddress!, byteCount: bytes.count) }
|
||||
var data = Data(
|
||||
bytesNoCopy: pointer, count: bytes.count,
|
||||
deallocator: .custom { pointer, count in
|
||||
observation.record(UnsafeRawBufferPointer(start: pointer, count: count))
|
||||
pointer.deallocate()
|
||||
})
|
||||
#expect(throws: KeychainSecretError.self) { _ = try KeychainSecretStore.decodeKeySecrets(from: &data) }
|
||||
#expect(observation.count == 1 && observation.allZero)
|
||||
}
|
||||
|
||||
@Test func serializationFailureStillZeroesTheUnusedPassphrase() {
|
||||
let observation = EnvelopeZeroization()
|
||||
let consumed = SSHSecret("key")
|
||||
consumed.clear()
|
||||
let phrase = SSHSecret("saved")
|
||||
phrase.observeZeroization { observation.record($0) }
|
||||
#expect(throws: SSHSecret.Consumed.self) {
|
||||
_ = try KeychainSecretStore.encodeKeySecrets(privateKey: consumed, passphrase: phrase)
|
||||
}
|
||||
#expect(observation.count == 1 && observation.allZero)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated final class EnvelopeZeroization: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var observations: [Bool] = []
|
||||
var count: Int { lock.withLock { observations.count } }
|
||||
var allZero: Bool { lock.withLock { observations.allSatisfy { $0 } } }
|
||||
func record(_ bytes: UnsafeRawBufferPointer) {
|
||||
lock.withLock { observations.append(!bytes.isEmpty && bytes.allSatisfy { $0 == 0 }) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
|
||||
@Suite @MainActor
|
||||
struct PassphraseFormLayoutTests {
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [false, true])
|
||||
func passphraseActionsFitTheAuthenticationCard(language: String, allowsSaving: Bool) throws {
|
||||
let host = TermindDomain.Host(name: "Production", host: "server.example.invalid")
|
||||
let prompt = SSHAuthenticationPrompt(
|
||||
host: host, username: "deploy", requiresPassphrase: true, allowsSaving: allowsSaving)
|
||||
let view = NSHostingView(
|
||||
rootView: SSHAuthenticationForm(
|
||||
prompt: prompt, submit: { _, _, _, _, _, _ in }, cancel: {}, library: HostLibraryModel()
|
||||
).environment(\.locale, Locale(identifier: language)).frame(width: 420))
|
||||
let size = view.fittingSize
|
||||
#expect(size.width == 420)
|
||||
#expect(size.height > 100 && size.height < 400)
|
||||
view.frame = NSRect(origin: .zero, size: size)
|
||||
view.layoutSubtreeIfNeeded()
|
||||
if ProcessInfo.processInfo.environment["TERMIND_PASSPHRASE_PREVIEW"] == "1", allowsSaving {
|
||||
let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds))
|
||||
view.cacheDisplay(in: view.bounds, to: bitmap)
|
||||
let png = try #require(bitmap.representation(using: .png, properties: [:]))
|
||||
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(
|
||||
"termind-passphrase-macos-\(language).png")
|
||||
try png.write(to: url)
|
||||
print("Passphrase preview: \(url.path)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,7 +164,8 @@ struct PortForwardingJourneyTests {
|
||||
#expect(credentials.controller.state(for: credentialRule.id) == .awaitingAuthentication)
|
||||
}
|
||||
|
||||
@Test func encryptedKeyPassphraseResumesPreparationInTheJourney() async throws {
|
||||
@Test(arguments: [false, true])
|
||||
func encryptedKeyPassphraseResumesPreparationInTheJourney(save: Bool) async throws {
|
||||
let material = try SSHKeyMaterial.generate(.ed25519, comment: "forwarding", passphrase: "test-passphrase")
|
||||
let key = SSHKeyRef(
|
||||
label: "Encrypted", algorithm: .ed25519, publicKey: material.publicKey,
|
||||
@@ -185,9 +186,10 @@ struct PortForwardingJourneyTests {
|
||||
#expect(prompt.requiresPassphrase)
|
||||
controller.submitAuthentication(
|
||||
for: rule.id, promptID: prompt.id, identityID: identity.id, username: "deploy",
|
||||
password: nil, passphrase: "test-passphrase", keyID: key.id, save: false)
|
||||
password: nil, passphrase: "test-passphrase", keyID: key.id, save: save)
|
||||
try await wait { controller.state(for: rule.id) == .running(activeConnections: 0) }
|
||||
#expect(!context.journey.plainTextLog.contains("test-passphrase"))
|
||||
#expect(library.requiresPassphraseEntry(for: key) == !save)
|
||||
}
|
||||
|
||||
@Test func savedCredentialsContinueTheExplicitOpeningWithoutStartingOtherRules() async throws {
|
||||
|
||||
Reference in new issue
Block a user