feat(keychain): add single-host export and deferred public keys

This commit is contained in:
Kaede Akino committed 2026-10-03 13:48:19 +08:00
1 parent e19b4eedf3
commit 37b0de899f
43 files changed
+2464 -1008

No files matched your search

@@ -0,0 +1,57 @@
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
import Testing
@Suite @MainActor
struct DeferredKeyMetadataTests {
@Test func encryptedKeyWithoutKnownAlgorithmSurvivesStoreReopening() throws {
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appending(path: "vault.store")
let key = SSHKeyRef(
label: "Encrypted", algorithm: .unknown, credentialRef: "private-reference", hasPassphrase: true)
do {
let container = try TermindPersistence.makeContainer(url: url)
let library = HostLibraryModel(persistence: HostPersistence(context: container.mainContext))
try library.upsert(key)
}
for _ in 0..<2 {
let container = try TermindPersistence.makeContainer(url: url)
let library = HostLibraryModel(persistence: HostPersistence(context: container.mainContext))
let saved = try #require(library.key(key.id))
#expect(saved.algorithm == .unknown && saved.hasPassphrase)
#expect(saved.publicKey.isEmpty && saved.fingerprint.isEmpty)
}
}
@Test func v3CarriesUnknownAlgorithmsAndLegacyWritersRejectThem() throws {
let key = SSHKeyRef(
label: "Encrypted", algorithm: .unknown, credentialRef: "private-reference", hasPassphrase: true)
let payload = VaultRecordPayload.sshKey(key)
let envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
#expect(envelope.formatVersion == 3)
let decoded = try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope))
guard case .sshKey(let roundTrip) = decoded.payload else {
Issue.record("Missing key")
return
}
#expect(roundTrip.algorithm == .unknown && roundTrip.hasPassphrase && roundTrip.publicKey.isEmpty)
for version in [1, 2] {
let legacy = VaultEnvelope(
key: payload.key, version: envelope.version, payload: payload, formatVersion: version)
#expect(throws: (any Error).self) { try VaultSyncCodec.encode(legacy) }
}
}
@Test(arguments: [1, 2]) func legacyKeyRecordsRemainReadable(_ version: Int) throws {
let key = SSHKeyRef(label: "Legacy", algorithm: .rsa, publicKey: "public", keySize: 3072, hasPassphrase: true)
let payload = VaultRecordPayload.sshKey(key)
let envelope = VaultEnvelope(
key: payload.key, version: .baseline(writerID: UUID()), payload: payload, formatVersion: version)
#expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)).payload == payload)
}
}
+370 -166
View File
@@ -5,210 +5,414 @@ import Testing
@Suite @MainActor
struct KeyExportTests {
@Test func openingAndEmptySelectionNeverInstallAnything() async {
@Test func openingAndMissingSelectionNeverConnect() async throws {
let fixture = KeyExportFixture()
let recorder = KeyExportRecorder()
let model = fixture.model(recorder)
#expect(model.selected.isEmpty)
await model.export(to: [])
#expect(await recorder.calls.isEmpty)
#expect(!model.isFinished)
let model = fixture.model()
#expect(!model.canStart)
model.start()
#expect(await fixture.transport.connects == 0)
model.hostID = fixture.host.id
#expect(model.canStart)
#expect(fixture.library.keyExportUnavailableReason(for: fixture.host) == nil)
}
@Test func missingPublicKeyCannotBeExported() async {
@Test func existingPublicKeyExportsWithoutReadingPrivateMaterialAndAttachesCustomIdentity() async throws {
let fixture = KeyExportFixture()
let recorder = KeyExportRecorder()
let model = KeyExportModel(
keys: [SSHKeyRef(label: "Missing")], credentials: fixture.credentials,
services: KeyExportServices(install: recorder.install))
await model.export(to: [fixture.alpha.id])
#expect(await recorder.calls.isEmpty)
let model = fixture.model()
model.hostID = fixture.host.id
model.start()
try await wait { model.phase == .succeeded }
#expect(await fixture.transport.derivations == 0)
#expect(await fixture.transport.installed == [fixture.key.publicKey])
let host = try #require(fixture.library.host(fixture.host.id))
let identity = try #require(fixture.library.identity(for: host))
#expect(identity.isHostCustom && identity.ownerHostID == host.id)
#expect(identity.keyID == fixture.key.id && identity.username == "deploy")
#expect(fixture.library.identity(fixture.identity.id)?.keyID == nil)
#expect(fixture.library.host(fixture.other.id)?.identityID == fixture.identity.id)
}
@Test func submitRevalidatesChangedHostCapabilities() async {
let fixture = KeyExportFixture()
let recorder = KeyExportRecorder()
let model = fixture.model(recorder)
var changed = fixture.alpha
changed.sshEnabled = false
changed.telnetEnabled = true
#expect(fixture.library.saveHost(changed))
await model.export(to: [fixture.alpha.id, fixture.beta.id])
#expect(model.isFinished)
if case .failed = model.states[fixture.alpha.id] {} else { Issue.record("Expected unavailable host failure") }
#expect(model.states[fixture.beta.id] == .succeeded)
#expect(await recorder.calls.map(\.hostID) == [fixture.beta.id])
@Test func absentPublicKeyIsDerivedOnlyForTheCurrentExport() async throws {
let fixture = KeyExportFixture(publicKey: "")
try fixture.storePrivateKey()
let model = fixture.model()
model.hostID = fixture.host.id
model.start()
try await wait { model.phase == .succeeded }
#expect(await fixture.transport.derivations == 1)
#expect(await fixture.transport.installed == ["ssh-ed25519 derived"])
#expect(fixture.library.key(fixture.key.id)?.publicKey == "")
#expect(fixture.library.key(fixture.key.id)?.fingerprint == "")
}
@Test func preparationFailureDoesNotPreventOtherDestinations() async {
let fixture = KeyExportFixture()
let recorder = KeyExportRecorder()
let model = fixture.model(recorder)
var changed = fixture.alpha
changed.jumpHostID = fixture.untrusted.id
#expect(fixture.library.saveHost(changed))
await model.export(to: [fixture.alpha.id, fixture.beta.id])
if case .failed = model.states[fixture.alpha.id] {} else { Issue.record("Expected route preparation failure") }
#expect(model.states[fixture.beta.id] == .succeeded)
#expect(await recorder.calls.map(\.hostID) == [fixture.beta.id])
@Test func encryptedKeyWithoutSavedPassphraseRequiresTheExportForm() async throws {
let fixture = KeyExportFixture(publicKey: "", encrypted: true)
try fixture.storePrivateKey()
let model = fixture.model()
model.hostID = fixture.host.id
#expect(model.needsExportPassphrase && !model.canStart)
model.exportPassphrase = "unlock"
#expect(model.canStart)
model.start()
try await wait { model.phase == .succeeded }
#expect(model.authenticationPrompt == nil)
#expect(model.exportPassphrase.isEmpty)
#expect(await fixture.transport.passphrases == ["unlock"])
#expect(fixture.library.requiresPassphraseEntry(for: fixture.key))
}
@Test func installsOnlyTheChosenPublicKeyAndReportsPartialFailure() async {
let fixture = KeyExportFixture()
let recorder = KeyExportRecorder(failingHost: fixture.beta.id)
let model = fixture.model(recorder)
await model.export(to: [fixture.alpha.id, fixture.beta.id])
let calls = await recorder.calls
#expect(Set(calls.map(\.hostID)) == [fixture.alpha.id, fixture.beta.id])
#expect(calls.allSatisfy { $0.publicKeys == [fixture.key.publicKey] && $0.names == [fixture.key.label] })
#expect(calls.allSatisfy { $0.username == "deploy" && !$0.trustedKeys.isEmpty })
#expect(model.states[fixture.alpha.id] == .succeeded)
#expect(model.states[fixture.beta.id] == .failed("Fixture export failure"))
#expect(model.isFinished && !model.isExporting)
@Test func savedPassphraseIsReusedAndBadPassphraseReturnsToTheForm() async throws {
let fixture = KeyExportFixture(publicKey: "", encrypted: true)
try fixture.storePrivateKey(passphrase: "wrong")
await fixture.transport.requirePassphrase("correct")
let model = fixture.model()
model.hostID = fixture.host.id
#expect(!model.needsExportPassphrase)
model.start()
try await wait { model.passphraseError != nil }
#expect(model.phase == .editing && !model.journey.isPresented)
#expect(model.needsExportPassphrase && model.authenticationPrompt == nil)
#expect(await fixture.transport.connects == 0)
model.exportPassphrase = "correct"
model.saveExportPassphrase = true
model.start()
try await wait { model.phase == .succeeded }
guard case .software(let secret)? = try fixture.library.keyCredential(for: fixture.key) else {
Issue.record("Missing private key")
return
}
#expect(secret.passphrase == "correct")
#expect(fixture.library.key(fixture.key.id)?.publicKey == "")
}
@Test func exportsEverySelectedKeyOncePerRemainingHost() async {
@Test func missingHostIdentityUsesTheConnectionJourney() async throws {
let fixture = KeyExportFixture()
let recorder = KeyExportRecorder()
let keys = [fixture.key, SSHKeyRef(label: "Backup", publicKey: "ssh-ed25519 backup-public-key")]
let model = KeyExportModel(
keys: keys, credentials: fixture.credentials, services: KeyExportServices(install: recorder.install))
model.updateSelection([fixture.alpha.id, fixture.beta.id])
fixture.library.projectHostDeletion(fixture.alpha.id)
await model.export(to: [fixture.alpha.id, fixture.beta.id, fixture.beta.id])
let calls = await recorder.calls
#expect(calls.count == 1)
#expect(calls.first?.hostID == fixture.beta.id)
#expect(calls.first?.publicKeys == keys.map(\.publicKey))
#expect(calls.first?.names == keys.map(\.label))
#expect(model.targets.map(\.id) == [fixture.beta.id])
#expect(model.selected == [fixture.beta.id])
#expect(model.states == [fixture.beta.id: .succeeded])
var host = fixture.host
host.identityID = nil
#expect(fixture.library.saveHost(host))
let model = fixture.model()
model.hostID = host.id
model.start()
try await wait { model.authenticationPrompt != nil }
let prompt = try #require(model.authenticationPrompt)
#expect(model.journey.waitsForLocalIdentity)
model.submitAuthentication(
promptID: prompt.id, identityID: nil, username: "alice", password: "one-use",
passphrase: nil, keyID: nil, save: false)
try await wait { model.phase == .succeeded }
let saved = try #require(fixture.library.host(host.id))
let identity = try #require(fixture.library.identity(for: saved))
#expect(identity.isHostCustom && identity.username == "alice" && !identity.usesPassword)
}
@Test func removedDestinationsAndEmptyKeyBatchesNeverStartAnExport() async {
@Test func firstUseTrustIsPresentedAndCancellationNeverInstalls() async throws {
let fixture = KeyExportFixture()
let recorder = KeyExportRecorder()
let empty = KeyExportModel(
keys: [], credentials: fixture.credentials, services: KeyExportServices(install: recorder.install))
await empty.export(to: [fixture.alpha.id])
#expect(!empty.isExporting && !empty.isFinished)
let model = fixture.model(recorder)
fixture.library.projectHostDeletion(fixture.alpha.id)
await model.export(to: [fixture.alpha.id])
#expect(!model.isExporting && !model.isFinished)
#expect(model.selected.isEmpty && model.targets.isEmpty)
#expect(await recorder.calls.isEmpty)
await fixture.transport.challengeTrust()
let model = fixture.model()
model.hostID = fixture.host.id
model.start()
try await wait { model.connection.pendingHostKey != nil }
#expect(model.journey.checkpoint?.kind == .hostKey)
model.cancel()
try await waitAsync { await fixture.transport.disconnects > 0 }
#expect(await fixture.transport.installed.isEmpty)
#expect(fixture.library.knownHosts.isEmpty)
}
@Test func revokedTrustFailsWithoutConnectingAndDoesNotBlockOtherHosts() async {
@Test func acceptingTrustContinuesTheSameExport() async throws {
let fixture = KeyExportFixture()
let recorder = KeyExportRecorder()
let model = fixture.model(recorder)
let trust = fixture.library.knownHosts.filter { $0.hostname == fixture.alpha.host }
#expect(fixture.library.deleteKnownHosts(Set(trust.map(\.id))))
await model.export(to: [fixture.alpha.id, fixture.beta.id, fixture.untrusted.id])
#expect(await recorder.calls.map(\.hostID) == [fixture.beta.id])
if case .failed = model.states[fixture.alpha.id] {} else { Issue.record("Expected revoked trust failure") }
if case .failed = model.states[fixture.untrusted.id] {} else { Issue.record("Expected untrusted host failure") }
#expect(model.states[fixture.beta.id] == .succeeded)
await fixture.transport.challengeTrust()
let model = fixture.model()
model.hostID = fixture.host.id
model.start()
try await wait { model.connection.pendingHostKey != nil }
let prompt = try #require(model.connection.pendingHostKey)
model.acceptHostKey(prompt.observation, operationID: model.operationID, remember: true)
try await wait { model.phase == .succeeded }
#expect(fixture.library.knownHosts.count == 1)
#expect(await fixture.transport.connects == 1)
}
@Test func runningExportRejectsDuplicateSubmissions() async {
@Test func anOldTrustCallbackCannotApproveANewAttemptForTheSameHost() async throws {
let fixture = KeyExportFixture()
let recorder = KeyExportRecorder(blocks: true)
let model = fixture.model(recorder)
let task = Task { await model.export(to: [fixture.alpha.id]) }
await recorder.waitUntilStarted()
#expect(model.isExporting && !model.isFinished)
#expect(model.states[fixture.alpha.id] == .running)
await model.export(to: [fixture.alpha.id, fixture.beta.id])
#expect(model.selected == [fixture.alpha.id])
#expect(await recorder.calls.count == 1)
await recorder.resume()
await task.value
#expect(model.isFinished && !model.isExporting)
#expect(model.states[fixture.alpha.id] == .succeeded)
await model.export(to: [fixture.alpha.id])
#expect(await recorder.calls.count == 1)
await fixture.transport.challengeTrust()
let model = fixture.model()
model.hostID = fixture.host.id
model.start()
try await wait { model.connection.pendingHostKey != nil }
let observation = try #require(model.connection.pendingHostKey?.observation)
let oldOperation = model.operationID
model.cancel()
model.start()
try await wait { model.connection.pendingHostKey != nil }
model.acceptHostKey(observation, operationID: oldOperation, remember: true)
#expect(model.connection.pendingHostKey != nil && fixture.library.knownHosts.isEmpty)
#expect(await fixture.transport.installed.isEmpty)
model.acceptHostKey(observation, operationID: model.operationID, remember: false)
try await wait { model.phase == .succeeded }
}
@Test func aConnectionArrivingAfterCancellationIsClosedWithoutExporting() async throws {
let fixture = KeyExportFixture()
await fixture.transport.delayConnection()
let model = fixture.model()
model.hostID = fixture.host.id
model.start()
try await waitAsync { await fixture.transport.isWaiting }
model.cancel()
await fixture.transport.finishConnection()
try await waitAsync { await fixture.transport.disconnects > 0 }
#expect(await fixture.transport.installed.isEmpty)
#expect(fixture.library.host(fixture.host.id)?.identityID == fixture.identity.id)
}
@Test func jumpLoginAndKeyboardAuthenticationStayInTheJourney() async throws {
let fixture = KeyExportFixture()
var jump = fixture.other
jump.identityID = nil
#expect(fixture.library.saveHost(jump))
var target = fixture.host
target.jumpHostID = jump.id
#expect(fixture.library.saveHost(target))
await fixture.transport.challengeKeyboard()
let model = fixture.model()
model.hostID = target.id
model.start()
try await wait { model.authenticationPrompt != nil }
let prompt = try #require(model.authenticationPrompt)
#expect(prompt.host.id == jump.id)
model.submitAuthentication(
promptID: prompt.id, identityID: nil, username: "gateway", password: "temporary",
passphrase: nil, keyID: nil, save: false)
try await wait { model.connection.keyboardAuthentication.current != nil }
let challenge = try #require(model.connection.keyboardAuthentication.current)
#expect(challenge.hostID == jump.id)
model.connection.keyboardAuthentication.respond(challenge.id, answers: ["one-time-code"])
try await wait { model.phase == .succeeded }
#expect(await fixture.transport.keyboardAnswers == ["one-time-code"])
#expect(fixture.library.identity(for: jump) == nil)
let saved = try #require(fixture.library.host(target.id))
#expect(fixture.library.identity(for: saved)?.username == "deploy")
}
@Test func proxyAuthenticationIsSeparateFromTheAttachedSSHIdentity() async throws {
let fixture = KeyExportFixture()
var target = fixture.host
target.proxy = HostProxy(hostname: "proxy.invalid")
#expect(fixture.library.saveHost(target))
await fixture.transport.challengeProxy()
let model = fixture.model()
model.hostID = target.id
model.start()
try await wait { model.authenticationPrompt != nil }
let prompt = try #require(model.authenticationPrompt)
#expect(prompt.isProxy)
model.submitAuthentication(
promptID: prompt.id, identityID: nil, username: "proxy-user", password: "temporary",
passphrase: nil, keyID: nil, save: false)
try await wait { model.phase == .succeeded }
let saved = try #require(fixture.library.host(target.id))
#expect(fixture.library.identity(for: saved)?.username == "deploy")
#expect(saved.proxy?.identityID == nil)
#expect(await fixture.transport.connects == 2)
}
@Test func bindingFailureRollsBackAndRetryDoesNotExecuteAgain() async throws {
let fixture = KeyExportFixture()
let persistence = ExportPersistence(
snapshot: VaultSnapshot(
hosts: [fixture.host, fixture.other], identities: [fixture.identity], keys: [fixture.key]))
let library = HostLibraryModel(persistence: persistence)
let model = KeyExportModel(key: fixture.key, library: library, services: fixture.services)
model.hostID = fixture.host.id
persistence.failsSave = true
model.start()
try await wait { if case .attachmentFailed = model.phase { true } else { false } }
#expect(model.didExport)
#expect(library.host(fixture.host.id)?.identityID == fixture.identity.id)
#expect(library.identities.count == 1)
persistence.failsSave = false
model.retry()
#expect(model.phase == .succeeded)
#expect(await fixture.transport.installed.count == 1)
#expect(await fixture.transport.connects == 1)
}
@Test func attachingCopiesSavedPasswordWithoutSharingItsReference() throws {
let fixture = KeyExportFixture()
try fixture.library.upsert(fixture.identity, passwordUpdate: .replace("saved-password"))
try fixture.library.attachExportedKey(fixture.key.id, to: fixture.host.id, username: "deploy")
let host = try #require(fixture.library.host(fixture.host.id))
let custom = try #require(fixture.library.identity(for: host))
let shared = try #require(fixture.library.identity(fixture.identity.id))
#expect(custom.credentialRef != shared.credentialRef)
#expect(try fixture.library.editablePassword(for: custom) == "saved-password")
#expect(fixture.library.delete(identityID: custom.id))
#expect(try fixture.library.editablePassword(for: shared) == "saved-password")
}
@Test func scriptFailureRequiresExplicitRetryAndNeverAttaches() async throws {
let fixture = KeyExportFixture()
await fixture.transport.failInstall()
let model = fixture.model()
model.hostID = fixture.host.id
model.start()
try await wait { if case .failed = model.phase { true } else { false } }
#expect(model.executionStarted && !model.didExport)
#expect(fixture.library.host(fixture.host.id)?.identityID == fixture.identity.id)
#expect(await fixture.transport.installed.count == 1)
model.editOptions()
#expect(!model.journey.isPresented)
model.options.script = "exit 0"
#expect(model.canStart)
#expect(await fixture.transport.installed.count == 1)
}
@Test func invalidOptionsNeverConnect() {
let fixture = KeyExportFixture()
let model = fixture.model()
model.hostID = fixture.host.id
model.options.filename = "../authorized_keys"
#expect(!model.canStart)
model.options.filename = "keys"
model.options.script = " "
#expect(!model.canStart)
}
private func wait(_ condition: @MainActor () -> Bool) async throws {
try await waitAsync { condition() }
}
private func waitAsync(_ condition: () async -> Bool) async throws {
let deadline = ContinuousClock.now + .seconds(5)
while !(await condition()) {
guard ContinuousClock.now < deadline else { throw CocoaError(.coderInvalidValue) }
try await Task.sleep(for: .milliseconds(5))
}
}
}
@MainActor
struct KeyExportFixture {
let key = SSHKeyRef(label: "Deployment Key", publicKey: "ssh-ed25519 fixture-public-key")
let alpha: TermindDomain.Host
let beta: TermindDomain.Host
let untrusted: TermindDomain.Host
private struct KeyExportFixture {
let key: SSHKeyRef
let identity = Identity(name: "Shared", username: "deploy")
let host: TermindDomain.Host
let other: TermindDomain.Host
let library: HostLibraryModel
let credentials: CredentialLibraryModel
init() {
let identity = Identity(name: "Deployment", username: "deploy")
alpha = TermindDomain.Host(name: "Alpha", host: "alpha.example.invalid", identityID: identity.id)
beta = TermindDomain.Host(name: "Beta", host: "beta.example.invalid", identityID: identity.id)
untrusted = TermindDomain.Host(name: "Untrusted", host: "untrusted.example.invalid", identityID: identity.id)
var telnet = TermindDomain.Host(name: "Telnet", host: "telnet.example.invalid", identityID: identity.id)
telnet.sshEnabled = false
telnet.telnetEnabled = true
library = HostLibraryModel(
hosts: [alpha, beta, untrusted, telnet], identities: [identity],
knownHosts: [alpha, beta].map { KnownHostEntry(hostname: $0.host, fingerprint: "SHA256:fixture") })
credentials = CredentialLibraryModel(library: library)
let transport = ExportTransport()
init(publicKey: String = "ssh-ed25519 existing", encrypted: Bool = false) {
key = SSHKeyRef(label: "Deploy", publicKey: publicKey, credentialRef: "key", hasPassphrase: encrypted)
host = TermindDomain.Host(name: "First", host: "first.invalid", identityID: identity.id)
other = TermindDomain.Host(name: "Second", host: "second.invalid", identityID: identity.id)
library = HostLibraryModel(hosts: [host, other], keys: [key], identities: [identity])
}
func model(_ recorder: KeyExportRecorder) -> KeyExportModel {
KeyExportModel(
keys: [key], credentials: credentials, services: KeyExportServices(install: recorder.install))
var services: KeyExportServices {
KeyExportServices(derivePublicKey: transport.derive, connect: transport.connect)
}
func model() -> KeyExportModel { KeyExportModel(key: key, library: library, services: services) }
func storePrivateKey(passphrase: String? = nil) throws {
try library.upsert(
key, credential: .software(SSHPrivateKeySecret(privateKey: "private", passphrase: passphrase)))
}
}
actor KeyExportRecorder {
struct Call: Sendable {
let hostID: UUID
let publicKeys: [String]
let names: [String]
let username: String
let trustedKeys: Set<SSHHostKeyIdentity>
private actor ExportTransport: KeyExportSession {
var derivations = 0
var connects = 0
var disconnects = 0
var installed: [String] = []
var passphrases: [String] = []
var keyboardAnswers: [String] = []
private var expectedPassphrase: String?
private var asksTrust = false
private var installFails = false
private var delaysConnection = false
private var asksKeyboard = false
private var asksProxy = false
private var connectionContinuation: CheckedContinuation<Void, Never>?
var isWaiting: Bool { connectionContinuation != nil }
func requirePassphrase(_ value: String) { expectedPassphrase = value }
func challengeTrust() { asksTrust = true }
func challengeKeyboard() { asksKeyboard = true }
func challengeProxy() { asksProxy = true }
func failInstall() { installFails = true }
func delayConnection() { delaysConnection = true }
func finishConnection() {
connectionContinuation?.resume()
connectionContinuation = nil
}
private(set) var calls: [Call] = []
private let failingHost: UUID?
private let blocks: Bool
private var started: CheckedContinuation<Void, Never>?
private var release: CheckedContinuation<Void, Never>?
init(failingHost: UUID? = nil, blocks: Bool = false) {
self.failingHost = failingHost
self.blocks = blocks
func derive(_ key: SSHKeyRef, _ credential: SSHResolvedKeyCredential, _ override: SSHSecret?) async throws -> String
{
defer {
credential.clearSecrets()
override?.clear()
}
derivations += 1
if case .software(_, let saved) = credential, let passphrase = override ?? saved {
let value = try passphrase.withUnsafeCStringAndConsume { pointer, _ in String(cString: pointer) }
passphrases.append(value)
if let expectedPassphrase, value != expectedPassphrase { throw CocoaError(.coderInvalidValue) }
}
return "ssh-ed25519 derived"
}
func install(_ keys: [SSHAuthorizedKeyInstall], _ configuration: SSHConnectionConfiguration) async throws {
calls.append(
Call(
hostID: configuration.hostID, publicKeys: keys.map(\.publicKey), names: keys.map(\.name),
username: configuration.username, trustedKeys: configuration.expectedHostKeys))
if blocks {
await withCheckedContinuation { continuation in
release = continuation
started?.resume()
started = nil
func connect(_ request: SSHConnectionRequest, _ progress: SSHRouteProgressHandler?) async throws
-> any KeyExportSession
{
defer { request.configuration.clearSecrets() }
connects += 1
var opened = false
defer { if !opened { disconnects += 1 } }
if asksProxy {
asksProxy = false
throw TermindSSHError.proxyAuthentication(
hostID: request.configuration.hostID, serverChallenge: true, message: "Proxy authentication required")
}
if delaysConnection {
await withCheckedContinuation { connectionContinuation = $0 }
}
if asksTrust {
let config = request.configuration
let challenge = SSHHostKeyChallenge(
hostID: config.hostID,
observation: SSHHostKeyObservation(
hostname: config.host, port: config.port, algorithm: .ed25519, fingerprint: "SHA256:test"),
expectedHostKeys: [])
let decision = try await request.authorizeHostKey?(challenge)
guard decision == .accept else {
throw CancellationError()
}
}
if configuration.hostID == failingHost { throw ExportFailure() }
if asksKeyboard {
let config = request.configuration
let hop = config.jumps.first
let challenge = SSHKeyboardInteractiveChallenge(
connectionID: UUID(), hostID: hop?.id ?? config.hostID,
hostname: hop?.host ?? config.host, port: hop?.port ?? config.port,
username: hop?.username ?? config.username, round: 1, name: "Verification", instruction: "",
prompts: [.init(text: "Code", echo: false)])
keyboardAnswers = try await request.keyboardInteractive?(challenge) ?? []
}
opened = true
return self
}
func waitUntilStarted() async {
if !calls.isEmpty { return }
await withCheckedContinuation { started = $0 }
func install(publicKey: String, options: KeyExportOptions) async throws {
installed.append(publicKey)
if installFails { throw CocoaError(.fileWriteUnknown) }
}
func disconnect() async { disconnects += 1 }
}
func resume() {
release?.resume()
release = nil
@MainActor
private final class ExportPersistence: 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
}
private struct ExportFailure: LocalizedError {
var errorDescription: String? { "Fixture export failure" }
func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult {
VaultRemoteApplyResult(snapshot: snapshot, acceptedKeys: [], needsUpload: [])
}
}
@@ -44,7 +44,7 @@ struct SSHAgentForwardingPersistenceTests {
#expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)) == envelope)
envelope.formatVersion = 1
#expect(throws: (any Error).self) { try VaultSyncCodec.encode(envelope) }
#expect(SyncRecordFormats.vault.writingVersion(for: "sshKey") == 2)
#expect(SyncRecordFormats.vault.writingVersion(for: "sshKey") == 3)
#expect(SyncRecordFormats.vault.supports(kind: "host", version: 1))
}
}
@@ -54,7 +54,7 @@ struct WebAuthnVaultMetadataTests {
#expect(reloaded.storage == .external)
let payload = VaultRecordPayload.sshKey(reloaded)
let envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
#expect(envelope.formatVersion == 2)
#expect(envelope.formatVersion == SyncRecordFormats.vault.writingVersion(for: "sshKey"))
let decoded = try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope))
#expect(decoded.payload == payload)
let remoteContainer = try TermindPersistence.makeContainer(inMemory: true)
+325 -1
View File
@@ -2607,6 +2607,23 @@
}
}
},
"Add an SSH host before exporting a key." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "请先添加SSH主机,再导出密钥。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "請先新增SSH主機,再匯出金鑰。"
}
}
}
},
"Add and connect to a host before exporting a key to it." : {
"extractionState" : "manual",
"localizations" : {
@@ -2743,6 +2760,23 @@
}
}
},
"Advanced" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "高级"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "進階"
}
}
}
},
"Agent" : {
"extractionState" : "manual",
"localizations" : {
@@ -11473,6 +11507,23 @@
}
}
},
"Export and Attach" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "导出并绑定"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "匯出並綁定"
}
}
}
},
"Export as SKILL.md" : {
"comment" : "A button that exports a skill as a `.md` file.",
"extractionState" : "manual",
@@ -11492,6 +11543,23 @@
}
}
},
"Export to Host" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "导出到主机"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "匯出到主機"
}
}
}
},
"Export to Hosts" : {
"extractionState" : "manual",
"localizations" : {
@@ -11600,6 +11668,23 @@
}
}
},
"Exporting public key…" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "正在导出公钥…"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "正在匯出公鑰…"
}
}
}
},
"Exposed Tools" : {
"extractionState" : "manual",
"localizations" : {
@@ -11908,6 +11993,23 @@
}
}
},
"Filename — $2" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "文件名—$2"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "檔案名稱—$2"
}
}
}
},
"Files" : {
"extractionState" : "manual",
"localizations" : {
@@ -15586,6 +15688,23 @@
}
}
},
"Location — $1" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "目录—$1"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "目錄—$1"
}
}
}
},
"Locked" : {
"extractionState" : "manual",
"localizations" : {
@@ -19468,6 +19587,23 @@
}
}
},
"Optional. If empty, the public key is derived only when exporting." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "可选。留空时,仅在导出时派生公钥。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "選填。留空時,僅在匯出時衍生公鑰。"
}
}
}
},
"Orange" : {
"extractionState" : "manual",
"localizations" : {
@@ -20280,6 +20416,23 @@
}
}
},
"Pending Identification" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "待确认"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "待確認"
}
}
}
},
"Pending uploads: %lld" : {
"extractionState" : "manual",
"localizations" : {
@@ -21450,6 +21603,40 @@
}
}
},
"Public key exported and attached." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "公钥已导出并绑定。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "公鑰已匯出並綁定。"
}
}
}
},
"Public key exported. The host could not be updated." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "公钥已导出,但未能更新主机配置。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "公鑰已匯出,但未能更新主機設定。"
}
}
}
},
"Publishes commits to a remote." : {
"extractionState" : "manual",
"localizations" : {
@@ -23310,6 +23497,23 @@
}
}
},
"Retry Attachment" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "重试绑定"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "重試綁定"
}
}
}
},
"Retry Now" : {
"extractionState" : "manual",
"localizations" : {
@@ -24537,7 +24741,7 @@
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "指令碼"
"value" : "指令稿"
}
}
}
@@ -28601,6 +28805,29 @@
}
}
},
"The default script supports UNIX systems." : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "The default script supports UNIX systems."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "默认脚本适用于UNIX系统。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "預設腳本適用於UNIX系統。"
}
}
}
},
"The device path does not exist." : {
"extractionState" : "manual",
"localizations" : {
@@ -28737,6 +28964,40 @@
}
}
},
"The export did not complete. Check the remote file before running the script again." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "导出未完成。再次运行脚本前,请检查远程文件。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "匯出未完成。再次執行指令稿前,請檢查遠端檔案。"
}
}
}
},
"The export script exited with status %lld." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "导出脚本以状态码%lld退出。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "匯出指令稿以狀態碼%lld結束。"
}
}
}
},
"The extracted text is longer than 200,000 characters." : {
"extractionState" : "manual",
"localizations" : {
@@ -31971,6 +32232,52 @@
}
}
},
"Type %@" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Type %@"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "类型:%@"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "類型:%@"
}
}
}
},
"Type Unknown" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Type Unknown"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "类型未知"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "類型未知"
}
}
}
},
"Type something at the shell prompt before generating a command." : {
"extractionState" : "manual",
"localizations" : {
@@ -32506,6 +32813,23 @@
}
}
},
"Unlock this key to derive its public key for this export." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "解锁此密钥,为本次导出派生公钥。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "解鎖此金鑰,為本次匯出衍生公鑰。"
}
}
}
},
"Unnamed group" : {
"extractionState" : "manual",
"localizations" : {
@@ -10,6 +10,7 @@ public nonisolated enum ConnectionPurpose: String, Hashable, Sendable {
case serialSession
case metrics
case portForwarding
case keyExport
}
public nonisolated struct ConnectionEndpoint: Identifiable, Hashable, Sendable {
@@ -468,6 +469,7 @@ extension ConnectionPurpose {
case .serialSession: "serial session"
case .metrics: "metrics"
case .portForwarding: "port forwarding"
case .keyExport: "key export"
}
}
}
@@ -417,32 +417,8 @@ public final class HostLibraryModel: RemoteSessionHostStore {
return keys
}
/// Why a saved host cannot currently receive a key without stopping for
/// another question. Export is deliberately limited to hosts whose login
/// and host-key trust have already been established elsewhere in the app.
public func keyExportUnavailableReason(for host: Host) -> String? {
guard isEligible(host, for: .keyExport) else {
return HostProtocolUnavailableError(useCase: .keyExport).localizedDescription
}
guard let identity = identity(for: host) else {
return String(localized: "No identity")
}
guard !identity.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return String(localized: "No username")
}
let hasAvailablePassword = passwordAvailability(for: identity) == .available
let hasUsableKey =
identity.keyID.flatMap { key($0) }.map {
keyAvailability(for: $0) == .available
} == true
guard !identity.hasSecret || hasAvailablePassword || hasUsableKey else {
return String(localized: "No saved credential")
}
let knownHosts = knownHosts(for: host)
guard !knownHosts.isEmpty else {
return String(localized: "Connect once to trust this host")
}
return nil
isEligible(host, for: .keyExport) ? nil : HostProtocolUnavailableError(useCase: .keyExport).localizedDescription
}
/// Resolves model references and Keychain secrets before work leaves the
@@ -1225,6 +1201,55 @@ public final class HostLibraryModel: RemoteSessionHostStore {
return saved
}
/// Commit the destination's private login and host reference together. Reusable
/// identities (and their password references) are never changed by an export.
public func attachExportedKey(_ keyID: SSHKeyRef.ID, to hostID: Host.ID, username: String) throws {
guard let index = hosts.firstIndex(where: { $0.id == hostID }), key(keyID) != nil,
isEligible(hosts[index], for: .keyExport), !username.isEmpty
else { throw TermindSSHError.missingCredential }
let host = hosts[index]
let source = identity(for: host)
let owned = customIdentity(for: hostID, purpose: .ssh)
var destination =
owned ?? Identity(name: host.name, username: username, ownerHostID: hostID, customPurpose: .ssh)
destination.username = username
destination.keyID = keyID
var copiedReference: String?
if source?.id != owned?.id || owned == nil {
destination.credentialRef = ""
if let source, source.usesPassword {
guard let password = try editablePassword(for: source) else {
throw TermindSSHError.missingCredential
}
let reference = "termind.password.\(UUID().uuidString)"
destination.passwordStorage = source.passwordStorage
try keychain.setString(password, for: reference, storage: destination.passwordStorage)
destination.credentialRef = reference
copiedReference = reference
}
}
let previousIdentities = identities
let previousHosts = hosts
if let identityIndex = identities.firstIndex(where: { $0.id == destination.id }) {
identities[identityIndex] = destination
} else {
identities.append(destination)
}
hosts[index].sshIdentityID = destination.id
guard persistCore() else {
identities = previousIdentities
hosts = previousHosts
if let copiedReference {
try keychain.removePassword(copiedReference, storage: destination.passwordStorage)
}
throw TermindSSHError.library(storageError ?? String(localized: "Unable to Save"))
}
if let owned, !owned.credentialRef.isEmpty, owned.credentialRef != destination.credentialRef {
try? keychain.removePassword(owned.credentialRef, storage: owned.passwordStorage)
}
didChangeHostConnection?(hostID)
}
/// Updates an Identity and its Keychain password as one operation.
///
/// The editor supplies intent rather than an optional string so a blank
@@ -1547,7 +1572,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
)
}
if !key.certificate.isEmpty {
_ = try SSHUserCertificate.parse(key.certificate, matching: key.publicKey)
_ = try SSHUserCertificate.parse(key.certificate, matching: key.publicKey.isEmpty ? nil : key.publicKey)
}
let keySnapshot = keys
let changesLocation =
@@ -4,87 +4,428 @@ import TermindDomain
import struct TermindDomain.Host
public struct KeyExportServices: Sendable {
public let install: @Sendable ([SSHAuthorizedKeyInstall], SSHConnectionConfiguration) async throws -> Void
public init(
install: @escaping @Sendable ([SSHAuthorizedKeyInstall], SSHConnectionConfiguration) async throws -> Void
) {
self.install = install
}
}
@MainActor
@Observable
public final class KeyExportModel {
public enum ExportState: Equatable {
case running
case succeeded
public enum Phase: Equatable {
case editing, preparing, authenticating, exporting, succeeded
case failed(String)
case attachmentFailed(String)
}
public let keys: [SSHKeyRef]
public private(set) var selected: Set<Host.ID> = []
public private(set) var states: [Host.ID: ExportState] = [:]
public private(set) var isExporting = false
public private(set) var isFinished = false
public private(set) var targets: [Host] = []
private let credentials: CredentialLibraryModel
public let keyID: SSHKeyRef.ID
public var key: SSHKeyRef? { library.key(keyID) }
public var hostID: Host.ID? {
didSet { if oldValue != hostID { cancel() } }
}
public var exportPassphrase = ""
public var saveExportPassphrase = false
public private(set) var passphraseError: String?
public var options: KeyExportOptions
public let connection = RemoteSessionConnectionController()
public var journey: ConnectionJourney { connection.journey }
public var operationID: UUID { generation }
public private(set) var phase: Phase = .editing
public private(set) var authenticationPrompt: SSHAuthenticationPrompt?
public private(set) var didExport = false
public private(set) var executionStarted = false
private let library: HostLibraryModel
private let services: KeyExportServices
private var task: Task<Void, Never>?
private var generation = UUID()
private var session: (any KeyExportSession)?
private var submissions: [Host.ID: SSHAuthenticationSubmission] = [:]
private var proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:]
private var derivedPublicKey: String?
private var derivationPassphrase: SSHSecret?
private var savesDerivationPassphrase = false
private var derivationPassphraseToSave: String?
private var username = ""
private var targetSnapshot: Host?
private var keySnapshot: SSHKeyRef?
private var routeSnapshot: [Host] = []
public init(keys: [SSHKeyRef], credentials: CredentialLibraryModel, services: KeyExportServices) {
self.keys = keys
self.credentials = credentials
public init(key: SSHKeyRef, library: HostLibraryModel, services: KeyExportServices) {
keyID = key.id
self.library = library
self.services = services
options = KeyExportOptions(script: KeyExportOptions.defaultScript(keyName: key.label))
}
public func updateSelection(_ ids: Set<Host.ID>) {
guard !isExporting, !isFinished else { return }
selected = ids
}
public func export(to hostIDs: [Host.ID]) async {
guard !keys.isEmpty, keys.allSatisfy({ !$0.publicKey.isEmpty }), !hostIDs.isEmpty, !isExporting, !isFinished
else { return }
selected = Set(hostIDs)
// Re-read saved destinations before preparing any credentials. Removed hosts
// are never contacted, and newly unavailable hosts receive an explicit failure.
targets = credentials.hosts.filter { selected.contains($0.id) }
selected.formIntersection(targets.map(\.id))
guard !targets.isEmpty else { return }
isExporting = true
var preparations: [(Host.ID, SSHConnectionPreparation)] = []
for host in targets {
if let reason = credentials.keyExportUnavailableReason(for: host) {
states[host.id] = .failed(reason)
continue
}
do {
preparations.append((host.id, try credentials.prepareSSHConnection(for: host)))
states[host.id] = .running
} catch {
states[host.id] = .failed(error.localizedDescription)
}
public var isBusy: Bool {
switch phase {
case .preparing, .authenticating, .exporting: true
default: false
}
let exportKeys = keys.map { SSHAuthorizedKeyInstall(publicKey: $0.publicKey, name: $0.label) }
let install = services.install
await withTaskGroup(of: (Host.ID, String?).self) { group in
for (id, preparation) in preparations {
group.addTask {
do {
let configuration = try await preparation.resolve()
try await install(exportKeys, configuration)
return (id, nil)
} catch {
return (id, error.localizedDescription)
}
public var needsExportPassphrase: Bool {
guard derivedPublicKey == nil || keySnapshot != key, let key,
key.publicKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else { return false }
return passphraseError != nil || (key.hasPassphrase && library.requiresPassphraseEntry(for: key))
}
public var canStart: Bool {
!isBusy && !didExport && key != nil && hosts.contains { $0.id == hostID } && options.isValid
&& (!needsExportPassphrase || !exportPassphrase.isEmpty)
}
public var hosts: [Host] { library.hosts(matching: "", for: .keyExport) }
public func start() {
guard canStart, task == nil, let hostID, let host = library.host(hostID), let key else { return }
if keySnapshot != key { derivedPublicKey = nil }
targetSnapshot = host
keySnapshot = key
routeSnapshot = library.route(for: host)
executionStarted = false
derivationPassphrase = exportPassphrase.isEmpty ? nil : SSHSecret(exportPassphrase)
derivationPassphraseToSave = saveExportPassphrase && !exportPassphrase.isEmpty ? exportPassphrase : nil
savesDerivationPassphrase = saveExportPassphrase
exportPassphrase = ""
passphraseError = nil
journey.begin(
host: host, route: routeSnapshot, purpose: .keyExport, username: library.identity(for: host)?.username)
launch()
}
public func retry() {
guard !isBusy else { return }
if didExport {
attach()
} else {
start()
}
}
public func editOptions() {
guard case .failed = phase else { return }
connection.disconnect()
}
public func cancel() {
generation = UUID()
task?.cancel()
task = nil
if let session { Task { await session.disconnect() } }
session = nil
authenticationPrompt = nil
connection.disconnect()
clearSecrets()
derivedPublicKey = nil
didExport = false
executionStarted = false
phase = .editing
exportPassphrase = ""
passphraseError = nil
targetSnapshot = nil
keySnapshot = nil
routeSnapshot = []
}
public func acceptHostKey(_ observation: SSHHostKeyObservation, operationID: UUID, remember: Bool) {
guard operationID == generation else { return }
guard isBusy, snapshotsAreCurrent else {
cancel()
return
}
if remember {
connection.trustHostKey(observation, using: library)
} else {
_ = connection.useTemporaryHostKey(observation)
}
}
public func submitAuthentication(
promptID: UUID, identityID: Identity.ID?, username: String, password: String?,
passphrase: String?, keyID: SSHKeyRef.ID?, save: Bool
) {
guard let prompt = authenticationPrompt, prompt.id == promptID, snapshotsAreCurrent, task == nil else { return }
do {
if prompt.isProxy {
let submission = SSHProxyAuthenticationSubmission(
username: username, password: password, identityID: identityID)
if save {
_ = try library.saveProxyAuthentication(
submission, for: library.host(prompt.host.id) ?? prompt.host,
selectedIdentity: identityID.flatMap(library.identity),
suppliedPassword: password)
refreshRouteSnapshot()
}
proxySubmissions[prompt.host.id] = submission
} else if prompt.requiresPassphrase {
submissions[prompt.host.id] = try library.passphraseSubmission(
for: prompt, existing: submissions[prompt.host.id], passphrase: passphrase, save: save)
} else {
let identity = identityID.flatMap(library.identity)
let name = (identity?.username ?? username).trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { throw TermindSSHError.missingUsername }
let selectedKey = identity?.keyID ?? keyID
if save {
let saved = identity ?? Identity(name: name, username: name, keyID: selectedKey)
try library.upsert(saved, passwordUpdate: password.map(IdentityPasswordUpdate.replace) ?? .keep)
guard var host = library.host(prompt.host.id) else { throw TermindSSHError.missingCredential }
host.identityID = saved.id
guard library.saveHost(host) else {
throw TermindSSHError.library(library.storageError ?? String(localized: "Unable to Save"))
}
refreshRouteSnapshot()
}
submissions[prompt.host.id] = SSHAuthenticationSubmission(
username: name, password: password, passphrase: passphrase, keyID: selectedKey,
identityID: identityID)
}
authenticationPrompt = nil
journey.resume()
launch()
} catch {
var failed = prompt
failed.failureMessage = error.localizedDescription
authenticationPrompt = failed
}
}
private func refreshRouteSnapshot() {
if let hostID, let host = library.host(hostID) {
targetSnapshot = host
routeSnapshot = library.route(for: host)
}
}
private var snapshotsAreCurrent: Bool {
keySnapshot == key && targetSnapshot?.id == hostID
&& routeSnapshot.allSatisfy { old in library.host(old.id)?.hasSameConnectionRoute(as: old) == true }
}
private func check(_ id: UUID) throws {
try Task.checkCancellation()
guard generation == id, snapshotsAreCurrent else { throw CancellationError() }
}
private func launch() {
guard task == nil, let host = targetSnapshot, let key = keySnapshot else { return }
let id = UUID()
generation = id
phase = .preparing
let options = options
task = Task { [weak self] in
guard let self else { return }
do {
try check(id)
guard options.isValid, library.keyExportUnavailableReason(for: host) == nil else {
throw HostProtocolUnavailableError(useCase: .keyExport)
}
let publicKey: String
if !key.publicKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
publicKey = key.publicKey
} else if let derivedPublicKey {
publicKey = derivedPublicKey
} else {
guard let credential = try library.keySecretCredential(for: key) else {
throw TermindSSHError.missingCredential
}
defer { credential.clearSecrets() }
if key.hasPassphrase && !credential.hasSavedPassphrase && derivationPassphrase == nil {
requestDerivationPassphrase(failure: String(localized: "Passphrase required"))
task = nil
return
}
let passphrase = derivationPassphrase
derivationPassphrase = nil
defer { passphrase?.clear() }
do {
publicKey = try await services.derivePublicKey(key, credential, passphrase)
} catch {
try check(id)
guard key.hasPassphrase else { throw error }
clearSecrets()
requestDerivationPassphrase(failure: error.localizedDescription)
task = nil
return
}
try check(id)
if savesDerivationPassphrase, let derivationPassphraseToSave {
try library.saveKeyPassphrase(derivationPassphraseToSave, for: key.id)
}
derivationPassphraseToSave = nil
savesDerivationPassphrase = false
self.derivedPublicKey = publicKey
}
if !key.certificate.isEmpty { _ = try SSHUserCertificate.parse(key.certificate, matching: publicKey) }
try check(id)
if let pending = proxySubmissions.firstHostRequiringProxyPassword(in: routeSnapshot) {
requestAuthentication(
.proxyAuthentication(
hostID: pending.id, serverChallenge: false,
message: String(localized: "The proxy password is unavailable.")))
task = nil
return
}
if let pending = submissions.firstHostRequiringPassword(in: routeSnapshot) {
requestAuthentication(
.routeHost(
hostID: pending.id, authentication: true, serverChallenge: false, hostKey: false,
message: TermindSSHError.missingCredential.localizedDescription))
task = nil
return
}
if let pending = submissions.firstHostRequiringPassphrase(
in: routeSnapshot, key: library.key, requiresEntry: library.requiresPassphraseEntry)
{
requestAuthentication(
.routeHost(
hostID: pending.id, authentication: true, serverChallenge: false, hostKey: false,
message: TermindSSHError.keyPassphraseRequired.localizedDescription))
task = nil
return
}
let attemptSubmissions = submissions.takeAuthenticationSecrets()
let attemptProxies = proxySubmissions.takeProxyAuthenticationSecrets()
let preparation = try library.prepareSSHConnection(
for: host, submissions: attemptSubmissions, proxySubmissions: attemptProxies,
temporaryHostKeys: connection.temporaryHostKeys)
let configuration = try await preparation.resolve(trustPolicy: .allowChallenge)
defer { configuration.clearSecrets() }
try check(id)
username = configuration.username
phase = .authenticating
let request = SSHConnectionRequest(
configuration: configuration,
authorizeHostKey: { [weak self] challenge in
guard let self else { return .reject }
return try await self.authorize(challenge, generation: id)
},
keyboardInteractive: { [weak self] challenge in
guard let self else { throw CancellationError() }
return try await self.answer(challenge, generation: id)
},
securityKeySigning: { [weak self] request in
guard let self else { throw CancellationError() }
return try await self.sign(request, generation: id)
})
let opened = try await services.connect(
request,
connection.progressHandler(attemptID: id, isCurrent: { [weak self] in self?.generation == $0 }))
do { try check(id) } catch {
await opened.disconnect()
throw error
}
session = opened
phase = .exporting
executionStarted = true
do {
try await opened.install(publicKey: publicKey, options: options)
await opened.disconnect()
} catch {
await opened.disconnect()
throw error
}
try check(id)
session = nil
task = nil
didExport = true
clearSecrets()
derivedPublicKey = nil
connection.disconnect()
attach()
} catch {
guard generation == id else { return }
session = nil
task = nil
if error is CancellationError {
cancel()
} else if !executionStarted, let ssh = error as? TermindSSHError,
ssh.isLocalAuthenticationRequirement || ssh.shouldRequestAuthentication
{
requestAuthentication(ssh)
} else {
clearSecrets()
let message =
executionStarted
? String(
localized:
"The export did not complete. Check the remote file before running the script again.")
+ "\n" + error.localizedDescription
: error.localizedDescription
phase = .failed(message)
journey.fail(message)
}
}
for await (id, error) in group {
states[id] = error.map(ExportState.failed) ?? .succeeded
}
}
private func authorize(_ challenge: SSHHostKeyChallenge, generation: UUID) async throws -> SSHHostKeyDecision {
try check(generation)
let decision = await connection.authorizeHostKey(challenge)
try check(generation)
return decision
}
private func answer(_ challenge: SSHKeyboardInteractiveChallenge, generation: UUID) async throws -> [String] {
try check(generation)
let answers = try await connection.answerKeyboardInteractive(challenge)
try check(generation)
return answers
}
private func sign(_ request: SecurityKeySigningRequest, generation: UUID) async throws -> SecurityKeyAssertion {
try check(generation)
let assertion = try await connection.signWithSecurityKey(request)
try check(generation)
return assertion
}
private func requestDerivationPassphrase(failure: String) {
passphraseError = failure
phase = .editing
journey.hide()
}
private func requestAuthentication(_ error: TermindSSHError) {
guard let host = library.host(error.routeHostID ?? hostID ?? UUID()) else {
clearSecrets()
phase = .failed(error.localizedDescription)
journey.fail(error.localizedDescription)
return
}
if error.isProxyAuthentication {
authenticationPrompt = SSHAuthenticationPromptResolver.proxy(
for: host, using: library, submission: proxySubmissions[host.id],
failureMessage: error.localizedDescription,
mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity)
journey.waitForProxyAuthentication(at: host.id, failure: error.localizedDescription)
} else {
authenticationPrompt = SSHAuthenticationPromptResolver.host(
host, using: library, submission: submissions[host.id], failureMessage: error.localizedDescription,
mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity)
if authenticationPrompt?.requiresPassphrase == true {
journey.waitForLocalPassphrase(at: host.id, failure: error.localizedDescription)
} else if error.isServerAuthenticationChallenge {
journey.waitForAuthentication(at: host.id, failure: error.localizedDescription)
} else {
journey.waitForLocalIdentity(at: host.id, failure: error.localizedDescription)
}
}
isExporting = false
isFinished = true
phase = .authenticating
}
private func attach() {
guard didExport, let hostID else { return }
do {
guard snapshotsAreCurrent else { throw TermindSSHError.missingCredential }
try library.attachExportedKey(keyID, to: hostID, username: username)
phase = .succeeded
} catch {
phase = .attachmentFailed(error.localizedDescription)
}
}
private func clearSecrets() {
for id in submissions.keys { submissions[id]?.clearSecrets() }
for id in proxySubmissions.keys { proxySubmissions[id]?.clearSecrets() }
submissions.removeAll()
proxySubmissions.removeAll()
derivationPassphrase?.clear()
derivationPassphrase = nil
derivationPassphraseToSave = nil
savesDerivationPassphrase = false
}
}
@@ -0,0 +1,61 @@
import Foundation
import TermindDomain
public nonisolated struct KeyExportOptions: Equatable, Sendable {
public var directory: String
public var filename: String
public var script: String
public init(directory: String = ".ssh", filename: String = "authorized_keys", script: String) {
self.directory = directory
self.filename = filename
self.script = script
}
public var isValid: Bool {
!directory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !filename.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !script.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !filename.contains("/") && !filename.contains("\\") && ![".", ".."].contains(filename)
&& !directory.contains("\0") && !filename.contains("\0") && !script.contains("\0")
}
public static func defaultScript(keyName: String) -> String {
let name = keyName.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.joined(
separator: " "
)
.filter { !$0.isNewline && $0 != "\0" }
let comment = RemoteShellCommand.escapeArgument("# \(name) Key by Termind")
return """
set -eu
umask 077
mkdir -p -- "$1"
touch -- "$1/$2"
chmod 700 "$1"
chmod 600 "$1/$2"
if ! LC_ALL=C grep -Fqx -- "$3" "$1/$2"; then
if [ -s "$1/$2" ]; then printf '\\n' >> "$1/$2"; fi
printf '%s\\n%s\\n' \(comment) "$3" >> "$1/$2"
fi
"""
}
}
public nonisolated protocol KeyExportSession: Sendable {
func install(publicKey: String, options: KeyExportOptions) async throws
func disconnect() async
}
public nonisolated struct KeyExportServices: Sendable {
public let derivePublicKey: @Sendable (SSHKeyRef, SSHResolvedKeyCredential, SSHSecret?) async throws -> String
public let connect: @Sendable (SSHConnectionRequest, SSHRouteProgressHandler?) async throws -> any KeyExportSession
public init(
derivePublicKey: @escaping @Sendable (SSHKeyRef, SSHResolvedKeyCredential, SSHSecret?) async throws -> String,
connect:
@escaping @Sendable (SSHConnectionRequest, SSHRouteProgressHandler?) async throws -> any KeyExportSession
) {
self.derivePublicKey = derivePublicKey
self.connect = connect
}
}
@@ -12,6 +12,7 @@ public nonisolated enum KeyStorage: String, Codable, Hashable, Sendable, CaseIte
}
public nonisolated enum SSHKeyAlgorithm: String, Codable, Hashable, Sendable, CaseIterable, Identifiable {
case unknown
case securityKey = "sk-ecdsa-sha2-nistp256@openssh.com"
case ed25519 = "ssh-ed25519"
case ecdsa256 = "ecdsa-sha2-nistp256"
@@ -1,6 +1,7 @@
extension SSHKeyAlgorithm {
public nonisolated var displayName: String {
switch self {
case .unknown: String(localized: "Pending Identification")
case .securityKey: "ECDSA-SK P-256"
case .ed25519: "Ed25519"
case .ecdsa256: "ECDSA P-256"
@@ -12,6 +13,7 @@ extension SSHKeyAlgorithm {
public nonisolated var typeName: String {
switch self {
case .unknown: String(localized: "Pending Identification")
case .securityKey: "Security Key"
case .ed25519: "Ed25519"
case .ecdsa256, .ecdsa384, .ecdsa521: "ECDSA"
@@ -209,6 +209,7 @@ public nonisolated enum SSHImportParser {
}
guard let name = field(), String(data: name, encoding: .utf8) == algorithm.rawValue else { return false }
switch algorithm {
case .unknown: return false
case .securityKey:
guard field() == Data("nistp256".utf8), let point = field(), point.count == 65,
point.first == 4, let application = field(), !application.isEmpty
@@ -46,7 +46,7 @@ public nonisolated struct SyncRecordFormats: Sendable {
public static let vault = Self([
"host": Format(readingVersions: [1, 2], writingVersion: 2),
"hostGroup": Format(readingVersions: [1, 2, 3], writingVersion: 3), "identity": v1,
"sshKey": Format(readingVersions: [1, 2], writingVersion: 2),
"sshKey": Format(readingVersions: [1, 2, 3], writingVersion: 3),
"knownHost": v1, "snippet": v1, "snippetPackage": v1,
"portForwarding": v1, "workspaceTemplate": v1,
// Legacy readers only; these cases cannot pass Vault upload eligibility.
@@ -89,6 +89,9 @@ public nonisolated enum VaultSyncCodec {
decoders[VaultRecordKind.hostGroup.rawValue]?[2] = {
try JSONDecoder().decode(VaultWireV2.Envelope.self, from: $0).domainValue
}
decoders[VaultRecordKind.sshKey.rawValue]?[3] = {
try JSONDecoder().decode(VaultWireV3.Envelope.self, from: $0).domainValue
}
decoders[VaultRecordKind.hostGroup.rawValue]?[3] = {
try JSONDecoder().decode(VaultWireV3.Envelope.self, from: $0).domainValue
}
@@ -247,6 +247,9 @@ nonisolated enum VaultWireV2 {
} else if case .hostGroup(let group) = envelope.payload {
payload = .hostGroup(try HostGroup(group))
} else if case .sshKey(let key) = envelope.payload {
guard SSHKeyAlgorithm(rawValue: key.algorithm.rawValue) != nil else {
throw CocoaError(.coderInvalidValue)
}
guard key.storage != .external || key.algorithm == .securityKey else {
throw CocoaError(.coderInvalidValue)
}
+183 -3
View File
@@ -1,7 +1,7 @@
import Foundation
import TermindDomain
/// Group-scoped Tailscale host identities require an explicit V3 wire contract.
/// V3 preserves group-scoped Tailscale identities and keys whose algorithm is not yet known.
nonisolated enum VaultWireV3 {
struct HostGroup: Codable {
var base: VaultWireV1.HostGroup
@@ -41,7 +41,181 @@ nonisolated enum VaultWireV3 {
}
}
typealias KeyStorage = VaultWireV1.KeyStorage
typealias TagColor = VaultWireV1.TagColor
enum SSHKeyAlgorithm: String, Codable {
case unknown
case securityKey = "sk-ecdsa-sha2-nistp256@openssh.com"
case ed25519 = "ssh-ed25519"
case ecdsa256 = "ecdsa-sha2-nistp256"
case ecdsa384 = "ecdsa-sha2-nistp384"
case ecdsa521 = "ecdsa-sha2-nistp521"
case rsa = "ssh-rsa"
}
struct SecurityKeyReference: Codable {
enum Verification: String, Codable { case required, preferred, discouraged }
var credentialID: Data
var publicKeyX963: Data
var userVerification: Verification
var requiresUserPresence: Bool
init(_ value: TermindDomain.SecurityKeyCredential) {
credentialID = value.credentialID
publicKeyX963 = value.publicKeyX963
userVerification = Verification(rawValue: value.userVerification.rawValue)!
requiresUserPresence = value.requiresUserPresence
}
var domainValue: TermindDomain.SecurityKeyCredential {
TermindDomain.SecurityKeyCredential(
credentialID: credentialID, publicKeyX963: publicKeyX963,
userVerification: .init(rawValue: userVerification.rawValue)!,
requiresUserPresence: requiresUserPresence)
}
}
struct SSHKeyRef: Codable, Identifiable {
var id: UUID = UUID()
var label: String
var algorithm: SSHKeyAlgorithm = .ed25519
var publicKey: String = ""
var certificate: String = ""
var fingerprint: String = ""
var securityKey: SecurityKeyReference?
var credentialRef: String = ""
var storage: KeyStorage = .iCloud
var keySize: Int?
var hasPassphrase = false
var requiresUserPresence = false
var createdAt: Date = Date(timeIntervalSince1970: 0)
var colorTag: TagColor = .none
var sortIndex: Double = 0
init(
id: UUID = UUID(), label: String, algorithm: SSHKeyAlgorithm = .ed25519, publicKey: String = "",
certificate: String = "", fingerprint: String = "", credentialRef: String = "",
storage: KeyStorage = .iCloud,
securityKey: SecurityKeyReference? = nil, keySize: Int? = nil, hasPassphrase: Bool = false,
requiresUserPresence: Bool = false,
createdAt: Date = Date(timeIntervalSince1970: 0), colorTag: TagColor = .none, sortIndex: Double = 0
) {
self.id = id
self.label = label
self.algorithm = algorithm
self.publicKey = publicKey
self.certificate = certificate
self.fingerprint = fingerprint
self.securityKey = securityKey
self.credentialRef = credentialRef
self.storage = storage
self.keySize = keySize
self.hasPassphrase = hasPassphrase
self.requiresUserPresence = requiresUserPresence
self.createdAt = createdAt
self.colorTag = colorTag
self.sortIndex = sortIndex
}
private enum CodingKeys: String, CodingKey {
case id, label, algorithm, publicKey, certificate, fingerprint
case securityKey, credentialRef, storage, keySize, hasPassphrase, requiresUserPresence
case createdAt, colorTag, sortIndex
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
id: try container.decode(UUID.self, forKey: .id),
label: try container.decode(String.self, forKey: .label),
algorithm: try container.decode(SSHKeyAlgorithm.self, forKey: .algorithm),
publicKey: try container.decode(String.self, forKey: .publicKey),
certificate: try container.decode(String.self, forKey: .certificate),
fingerprint: try container.decode(String.self, forKey: .fingerprint),
credentialRef: try container.decode(String.self, forKey: .credentialRef),
storage: try container.decode(KeyStorage.self, forKey: .storage),
securityKey: try container.decodeIfPresent(SecurityKeyReference.self, forKey: .securityKey),
keySize: try container.decodeIfPresent(Int.self, forKey: .keySize),
hasPassphrase: try container.decode(Bool.self, forKey: .hasPassphrase),
requiresUserPresence: try container.decode(Bool.self, forKey: .requiresUserPresence),
createdAt: try container.decode(Date.self, forKey: .createdAt),
colorTag: (try? container.decode(TagColor.self, forKey: .colorTag)) ?? .none,
sortIndex: try container.decode(Double.self, forKey: .sortIndex)
)
}
init(_ value: TermindDomain.SSHKeyRef) {
self.securityKey = value.securityKey.map(SecurityKeyReference.init)
self.id = value.id
self.label = value.label
self.algorithm = SSHKeyAlgorithm(rawValue: value.algorithm.rawValue)!
self.publicKey = value.publicKey
self.certificate = value.certificate
self.fingerprint = value.fingerprint
self.credentialRef = value.credentialRef
// Retain the existing external-authenticator storage representation.
self.storage = value.storage == .external ? .thisDevice : KeyStorage(rawValue: value.storage.rawValue)!
self.keySize = value.keySize
self.hasPassphrase = value.hasPassphrase
self.requiresUserPresence = value.requiresUserPresence
self.createdAt = value.createdAt
self.colorTag = TagColor(rawValue: value.colorTag.rawValue)!
self.sortIndex = value.sortIndex
}
var domainValue: TermindDomain.SSHKeyRef {
var result = TermindDomain.SSHKeyRef(label: "")
result.securityKey = securityKey?.domainValue
result.id = id
result.label = label
result.algorithm = TermindDomain.SSHKeyAlgorithm(rawValue: algorithm.rawValue)!
result.publicKey = publicKey
result.certificate = certificate
result.fingerprint = fingerprint
result.credentialRef = credentialRef
result.storage =
algorithm == .securityKey ? .external : TermindDomain.KeyStorage(rawValue: storage.rawValue)!
result.keySize = keySize
result.hasPassphrase = hasPassphrase
result.requiresUserPresence = requiresUserPresence
result.createdAt = createdAt
result.colorTag = TermindDomain.TagColor(rawValue: colorTag.rawValue)!
result.sortIndex = sortIndex
return result
}
}
/// Shipped V2 has only control-plane-scoped host IDs. Keep its shape frozen.
struct TailscaleConfiguration: Codable {
var network: TailscaleNetwork
var username: String
var exitNodeID: String?
var hostPreferences: [String: TailscaleHostPreferences]
init(_ value: TailscaleGroupConfiguration) throws {
guard value.hostIdentityVersion == .legacy else { throw CocoaError(.coderInvalidValue) }
network = value.network
username = value.username
exitNodeID = value.exitNodeID
hostPreferences = Dictionary(
uniqueKeysWithValues: value.hostPreferences.map { ($0.key.uuidString.lowercased(), $0.value) })
}
private enum CodingKeys: String, CodingKey { case network, username, exitNodeID, hostPreferences }
init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
network = try values.decode(TailscaleNetwork.self, forKey: .network)
username = try values.decode(String.self, forKey: .username)
exitNodeID = try values.decodeIfPresent(String.self, forKey: .exitNodeID)
hostPreferences = try values.decode([String: TailscaleHostPreferences].self, forKey: .hostPreferences)
guard hostPreferences.keys.allSatisfy({ UUID(uuidString: $0)?.uuidString.lowercased() == $0 }) else {
throw DecodingError.dataCorruptedError(
forKey: .hostPreferences, in: values, debugDescription: "Invalid host identifier")
}
}
var domainValue: TailscaleGroupConfiguration {
var value = TailscaleGroupConfiguration(network: network, hostIdentityVersion: .legacy)
value.username = username
value.exitNodeID = exitNodeID
value.hostPreferences = Dictionary(
uniqueKeysWithValues: hostPreferences.map { (UUID(uuidString: $0.key)!, $0.value) })
return value
}
}
enum Payload: Codable {
case sshKey(SSHKeyRef)
case hostGroup(HostGroup)
}
@@ -53,12 +227,17 @@ nonisolated enum VaultWireV3 {
var payload: Payload?
init(_ envelope: VaultEnvelope) throws {
guard envelope.key.kind == .hostGroup else { throw CocoaError(.coderInvalidValue) }
guard [.hostGroup, .sshKey].contains(envelope.key.kind) else { throw CocoaError(.coderInvalidValue) }
formatVersion = 3
key = envelope.key
version = envelope.version
isDeleted = envelope.isDeleted
if case .hostGroup(let group) = envelope.payload {
if case .sshKey(let key) = envelope.payload {
guard key.storage != .external || key.algorithm == .securityKey else {
throw CocoaError(.coderInvalidValue)
}
payload = .sshKey(SSHKeyRef(key))
} else if case .hostGroup(let group) = envelope.payload {
payload = .hostGroup(HostGroup(group))
} else if envelope.payload == nil {
payload = nil
@@ -70,6 +249,7 @@ nonisolated enum VaultWireV3 {
var domainValue: VaultEnvelope {
let value: VaultRecordPayload?
switch payload {
case .sshKey(let key): value = .sshKey(key.domainValue)
case .hostGroup(let group): value = .hostGroup(group.domainValue)
case nil: value = nil
}
@@ -15,6 +15,7 @@ extension ConnectionPurpose {
case (.sftpChannel, .routeDestination): "folder.fill"
case (.serialSession, _): "cable.connector"
case (.metrics, _): "waveform.path.ecg"
case (.keyExport, _): "key.fill"
case (.portForwarding, _): "arrow.left.arrow.right"
}
}
@@ -89,7 +90,7 @@ public struct ConnectionJourneyHeader: View {
private var detail: String {
let protocolName: String =
switch journey.purpose {
case .sshSession, .metrics, .portForwarding: String(localized: AppStrings.Vault.ssh)
case .sshSession, .metrics, .portForwarding, .keyExport: String(localized: AppStrings.Vault.ssh)
case .telnetSession: String(localized: AppStrings.Vault.telnet)
case .sftpChannel: String(localized: AppStrings.SFTP.sftp)
case .serialSession: String(localized: AppStrings.Vault.serial)
@@ -131,6 +132,7 @@ public struct ConnectionJourneyStatus: View {
case .sshSession, .telnetSession, .serialSession: String(localized: AppStrings.Connections.terminalReady)
case .sftpChannel: String(localized: AppStrings.Connections.fileBrowserReady)
case .metrics: String(localized: AppStrings.Connections.metricsReady)
case .keyExport: String(localized: AppStrings.KeyExport.completed)
case .portForwarding: String(localized: AppStrings.Connections.forwardingReady)
}
case .failed(let message): message
@@ -0,0 +1,112 @@
import SwiftUI
import TermindApplication
import TermindDomain
public struct KeyExportForm: View {
@Bindable private var model: KeyExportModel
@State private var advanced = false
public init(model: KeyExportModel) { self.model = model }
public var body: some View {
Form {
if let key = model.key {
Section {
HStack(spacing: 12) {
Image(systemName: "key.fill").font(.title2).foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 4) {
Text(key.label).font(.headline)
Text(
key.algorithm == .unknown
? AppStrings.Keychain.unknownKeyType
: AppStrings.Keychain.keyType(key.algorithm.displayName)
).font(.caption).foregroundStyle(.secondary)
}
}
}
}
if model.phase == .succeeded {
Section {
Label(AppStrings.KeyExport.completed.swiftUIKey, systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
}
} else if case .attachmentFailed(let message) = model.phase {
Section {
Label(AppStrings.KeyExport.attachmentFailed.swiftUIKey, systemImage: "exclamationmark.triangle")
Text(message).font(.caption).foregroundStyle(.secondary).textSelection(.enabled)
Button(AppStrings.KeyExport.retryAttachment.swiftUIKey) { model.retry() }
}
} else {
if case .failed(let message) = model.phase {
Section {
Text(message).font(.caption).foregroundStyle(.red).textSelection(.enabled)
}
}
Section {
Picker(AppStrings.Vault.host.swiftUIKey, selection: $model.hostID) {
Text(AppStrings.KeyExport.selectHost).tag(UUID?.none)
ForEach(model.hosts) { host in Text(host.name).tag(Optional(host.id)) }
}
if model.hosts.isEmpty { Text(AppStrings.KeyExport.noHosts).foregroundStyle(.secondary) }
LabeledContent(AppStrings.KeyExport.location.swiftUIKey) {
TextField(
AppStrings.KeyExport.location.swiftUIKey, text: $model.options.directory.singleLineText
).labelsHidden().multilineTextAlignment(.trailing).singleLinePaste()
}
LabeledContent(AppStrings.KeyExport.filename.swiftUIKey) {
TextField(
AppStrings.KeyExport.filename.swiftUIKey, text: $model.options.filename.singleLineText
)
.labelsHidden().multilineTextAlignment(.trailing).singleLinePaste()
}
}
if model.needsExportPassphrase {
Section {
RevealableSecretField(AppStrings.Keychain.passphrase, text: $model.exportPassphrase)
Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $model.saveExportPassphrase)
if let error = model.passphraseError { Text(error).font(.caption).foregroundStyle(.red) }
} footer: {
Text(AppStrings.KeyExport.passphraseHelp)
}
}
Section {
Text(AppStrings.KeyExport.scriptHelp).font(.caption).foregroundStyle(.secondary)
DisclosureGroup(AppStrings.KeyExport.advanced.swiftUIKey, isExpanded: $advanced) {
scriptEditor
}
}
}
}
.formStyle(.grouped)
.disabled(model.isBusy)
#if os(iOS)
.textInputAutocapitalization(.never).autocorrectionDisabled()
#endif
}
private var scriptFont: Font {
#if os(macOS)
.caption.monospaced()
#else
.body.monospaced()
#endif
}
private var scriptEditor: some View {
TextEditor(text: $model.options.script)
.font(scriptFont)
.multilineTextAlignment(.leading)
.frame(minHeight: 240)
.scrollContentBackground(.hidden)
.overlay(alignment: .topLeading) {
if model.options.script.isEmpty {
Text(AppStrings.KeyExport.script)
.font(scriptFont)
.foregroundStyle(.tertiary)
.padding(.leading, 5)
.allowsHitTesting(false)
}
}
.accessibilityLabel(AppStrings.KeyExport.script.swiftUIKey)
}
}
@@ -0,0 +1,28 @@
import Foundation
extension AppStrings {
public nonisolated enum KeyExport {
public static var exportAndAttach: LocalizedStringResource { "Export and Attach" }
public static var completed: LocalizedStringResource { "Public key exported and attached." }
public static var attachmentFailed: LocalizedStringResource {
"Public key exported. The host could not be updated."
}
public static var retryAttachment: LocalizedStringResource { "Retry Attachment" }
public static var location: LocalizedStringResource { "Location — $1" }
public static var filename: LocalizedStringResource { "Filename — $2" }
public static var advanced: LocalizedStringResource { "Advanced" }
public static var script: LocalizedStringResource { "Script" }
public static var scriptHelp: LocalizedStringResource {
"The default script supports UNIX systems."
}
public static var optionalPublicKey: LocalizedStringResource {
"Optional. If empty, the public key is derived only when exporting."
}
public static var passphraseHelp: LocalizedStringResource {
"Unlock this key to derive its public key for this export."
}
public static var exporting: LocalizedStringResource { "Exporting public key…" }
public static var selectHost: LocalizedStringResource { "Select Host" }
public static var noHosts: LocalizedStringResource { "Add an SSH host before exporting a key." }
}
}
@@ -4,6 +4,8 @@ extension AppStrings {
public nonisolated enum Keychain {
public static var savePassphrase: LocalizedStringResource { "Save Passphrase" }
public static var saveAndContinue: LocalizedStringResource { "Save and Continue" }
public static func keyType(_ name: String) -> LocalizedStringResource { "Type \(name)" }
public static var unknownKeyType: LocalizedStringResource { "Type Unknown" }
public static var addAndConnectToAHostBeforeExportingAKeyToIt: LocalizedStringResource {
"Add and connect to a host before exporting a key to it."
@@ -97,8 +99,8 @@ extension AppStrings {
"Elliptic Curve"
}
public static var exportToHosts: LocalizedStringResource {
"Export to Hosts"
public static var exportToHost: LocalizedStringResource {
"Export to Host"
}
public static var exported: LocalizedStringResource {
@@ -123,6 +123,25 @@ public nonisolated final class RemoteConnectionCoordinator: Sendable {
}
}
public func keyExportServices() -> KeyExportServices {
KeyExportServices(
derivePublicKey: { key, credential, passphrase in
try await SSHKeyExportSession.derivePublicKey(key: key, credential: credential, passphrase: passphrase)
},
connect: { [self] request, progress in
let connection = SSHConnection(
keyboardInteractive: keyboardInteractive, logger: logger, maxSessionsPolicy: maxSessionsPolicy)
do {
try await connection.connect(using: request, progress: progress, connectionFailure: nil)
try Task.checkCancellation()
return SSHKeyExportSession(connection: connection)
} catch {
await connection.disconnect()
throw error
}
})
}
public func installAuthorizedKey(
_ publicKey: String, named keyName: String,
using configuration: SSHConnectionConfiguration
@@ -0,0 +1,58 @@
import Foundation
import TermindApplication
import TermindDomain
nonisolated struct SSHKeyExportSession: KeyExportSession {
let connection: any SSHConnectionProtocol
func install(publicKey: String, options: KeyExportOptions) async throws {
let command = try Self.command(publicKey: publicKey, options: options)
let result = try await connection.execute(
command, workingDirectory: nil, stdin: nil, timeout: 20, outputLimit: 64 * 1024, onOutput: nil)
guard result.exitCode == 0 else {
throw TermindSSHError.library(
String(localized: "The export script exited with status \(result.exitCode).")
+ "\n" + result.stderr.trimmingCharacters(in: .whitespacesAndNewlines))
}
}
func disconnect() async { await connection.disconnect() }
static func command(publicKey: String, options: KeyExportOptions) throws -> String {
guard options.isValid else { throw CocoaError(.coderInvalidValue) }
let key = try SSHKeyMaterial.publicMaterial(publicKey).publicKey
let launcher = """
case "$1" in
/*) directory=$1 ;;
'~') directory=$HOME ;;
'~/'*) directory="$HOME/${1#??}" ;;
*) directory="$HOME/$1" ;;
esac
exec sh -c "$4" termind-key-export "$directory" "$2" "$3"
"""
return "sh -c "
+ [launcher, "termind-key-export", options.directory, options.filename, key, options.script]
.map(RemoteShellCommand.escapeArgument).joined(separator: " ")
}
static func derivePublicKey(
key: SSHKeyRef, credential: SSHResolvedKeyCredential, passphrase: SSHSecret?
) async throws -> String {
try await Task.detached(priority: .userInitiated) {
defer {
credential.clearSecrets()
passphrase?.clear()
}
guard case .software(let privateKey, let saved) = credential else {
throw TermindSSHError.missingCredential
}
let password = passphrase ?? saved
let passwordText = try password?.withUnsafeCStringAndConsume { bytes, _ in String(cString: bytes) }
return try privateKey.withUnsafeCStringAndConsume { bytes, _ in
try SSHKeyMaterial.inspect(
privateKey: String(cString: bytes), passphrase: passwordText, comment: key.label
).publicKey
}
}.value
}
}
@@ -0,0 +1,180 @@
import Foundation
import OpenSSL
import TermindDomain
nonisolated extension SSHKeyMaterial {
public struct PrivateKeyMetadata: Sendable {
public var algorithm: SSHKeyAlgorithm
public var keySize: Int?
public var isEncrypted: Bool
}
/// Reads the key envelope without deriving or populating a public key. An encrypted
/// PKCS#8 envelope deliberately leaves its algorithm unknown until it is unlocked.
public static func privateKeyMetadata(_ text: String) throws -> PrivateKeyMetadata {
let lines = text.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines)
guard let header = lines.first, let footer = lines.last,
header.hasPrefix("-----BEGIN "), header.hasSuffix("PRIVATE KEY-----"),
footer == header.replacingOccurrences(of: "BEGIN", with: "END")
else { throw SSHKeyMaterialError.invalidPrivateKey("Invalid private key envelope.") }
let body = lines.dropFirst().dropLast().filter { !$0.contains(":") }.joined()
guard let bytes = Data(base64Encoded: body), !bytes.isEmpty else {
throw SSHKeyMaterialError.invalidPrivateKey("Invalid private key encoding.")
}
if header == "-----BEGIN OPENSSH PRIVATE KEY-----" {
let magic = Data("openssh-key-v1\0".utf8)
guard bytes.starts(with: magic) else { throw invalidEnvelope }
var offset = magic.count
guard let cipher = readSSHField(bytes, offset: &offset),
let kdf = readSSHField(bytes, offset: &offset),
let options = readSSHField(bytes, offset: &offset), offset + 4 <= bytes.count
else { throw invalidEnvelope }
let count = bytes[offset..<offset + 4].reduce(0) { ($0 << 8) | Int($1) }
offset += 4
guard count == 1, let publicBlob = readSSHField(bytes, offset: &offset),
let privateBlob = readSSHField(bytes, offset: &offset), !privateBlob.isEmpty,
offset == bytes.count
else { throw invalidEnvelope }
var publicOffset = 0
guard let method = readSSHField(publicBlob, offset: &publicOffset),
let name = String(data: method, encoding: .utf8)
else { throw invalidEnvelope }
let material = try publicMaterial("\(name) \(publicBlob.base64EncodedString())")
let encrypted = cipher != Data("none".utf8)
guard encrypted || (kdf == Data("none".utf8) && options.isEmpty && privateBlob.count >= 8) else {
throw invalidEnvelope
}
if !encrypted { try validateOpenSSHPrivateBlock(privateBlob, algorithm: material.algorithm) }
return PrivateKeyMetadata(
algorithm: material.algorithm, keySize: material.keySize, isEncrypted: encrypted)
}
if header == "-----BEGIN ENCRYPTED PRIVATE KEY-----" {
let valid = bytes.withUnsafeBytes { buffer -> Bool in
var pointer = buffer.bindMemory(to: UInt8.self).baseAddress
let start = pointer
guard let value = d2i_X509_SIG(nil, &pointer, bytes.count) else { return false }
defer { X509_SIG_free(value) }
return pointer == start?.advanced(by: bytes.count)
}
guard valid else { throw invalidEnvelope }
return PrivateKeyMetadata(algorithm: .unknown, keySize: nil, isEncrypted: true)
}
if lines.contains(where: { $0.hasPrefix("DEK-Info:") }) {
guard lines.contains(where: { $0.contains("Proc-Type: 4,ENCRYPTED") }), bytes.count >= 16,
["-----BEGIN RSA PRIVATE KEY-----", "-----BEGIN EC PRIVATE KEY-----"].contains(header)
else { throw invalidEnvelope }
return PrivateKeyMetadata(
algorithm: header.contains("RSA") ? .rsa : .unknown, keySize: nil, isEncrypted: true)
}
return try text.withCString { buffer in
guard let bio = BIO_new_mem_buf(buffer, Int32(text.utf8.count)) else { throw invalidEnvelope }
defer { BIO_free(bio) }
guard let key = PEM_read_bio_PrivateKey(bio, nil, { _, _, _, _ in 0 }, nil) else {
throw invalidEnvelope
}
defer { EVP_PKEY_free(key) }
let bits = Int(EVP_PKEY_get_bits(key))
let algorithm: SSHKeyAlgorithm
switch EVP_PKEY_get_base_id(key) {
case EVP_PKEY_ED25519: algorithm = .ed25519
case EVP_PKEY_RSA: algorithm = .rsa
case EVP_PKEY_EC:
switch bits {
case 256: algorithm = .ecdsa256
case 384: algorithm = .ecdsa384
case 521: algorithm = .ecdsa521
default: throw SSHKeyMaterialError.unsupportedAlgorithm("EC \(bits)")
}
default: throw SSHKeyMaterialError.unsupportedAlgorithm("PEM")
}
return PrivateKeyMetadata(algorithm: algorithm, keySize: bits, isEncrypted: false)
}
}
private static func validateOpenSSHPrivateBlock(_ bytes: Data, algorithm: SSHKeyAlgorithm) throws {
guard bytes.count >= 8, bytes.prefix(4) == bytes.dropFirst(4).prefix(4) else { throw invalidEnvelope }
var offset = 8
guard readSSHField(bytes, offset: &offset) == Data(algorithm.rawValue.utf8) else { throw invalidEnvelope }
switch algorithm {
case .ed25519:
guard let publicKey = readSSHField(bytes, offset: &offset), publicKey.count == 32,
let privateKey = readSSHField(bytes, offset: &offset), privateKey.count == 64,
privateKey.suffix(32) == publicKey
else { throw invalidEnvelope }
case .rsa:
for _ in 0..<6 {
guard let field = readSSHField(bytes, offset: &offset), !field.isEmpty else { throw invalidEnvelope }
}
case .ecdsa256, .ecdsa384, .ecdsa521:
guard readSSHField(bytes, offset: &offset) == Data(algorithm.rawValue.dropFirst(11).utf8),
let point = readSSHField(bytes, offset: &offset), point.first == 4,
let scalar = readSSHField(bytes, offset: &offset), !scalar.isEmpty
else { throw invalidEnvelope }
case .unknown, .securityKey: throw invalidEnvelope
}
guard readSSHField(bytes, offset: &offset) != nil else { throw invalidEnvelope }
let padding = bytes.dropFirst(offset)
guard padding.count <= 255,
padding.enumerated().allSatisfy({ $0.element == UInt8($0.offset + 1) })
else { throw invalidEnvelope }
}
public static func imported(privateKey: String, publicKey: String) throws -> SSHKeyMaterial {
let metadata = try privateKeyMetadata(privateKey)
let publicKey = publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !publicKey.isEmpty else {
return SSHKeyMaterial(
privateKey: privateKey, publicKey: "", algorithm: metadata.algorithm,
fingerprint: "", keySize: metadata.keySize)
}
var material = try publicMaterial(publicKey)
guard material.algorithm != .securityKey,
metadata.algorithm == .unknown || material.algorithm == metadata.algorithm
else { throw SSHUserCertificateError.keyMismatch }
material.privateKey = privateKey
return material
}
/// Validates a single OpenSSH public key, including the method inside its wire blob.
public static func publicMaterial(_ text: String) throws -> SSHKeyMaterial {
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
let fields = text.split(whereSeparator: \.isWhitespace)
guard !text.contains("\n"), !text.contains("\r"), !text.contains("\0"), fields.count >= 2,
let algorithm = SSHKeyAlgorithm(rawValue: String(fields[0])), algorithm != .unknown,
let blob = Data(base64Encoded: String(fields[1]))
else { throw SSHUserCertificateError.invalidFormat }
var offset = 0
guard readSSHField(blob, offset: &offset) == Data(fields[0].utf8) else {
throw SSHUserCertificateError.invalidFormat
}
switch algorithm {
case .ed25519:
guard readSSHField(blob, offset: &offset)?.count == 32 else { throw SSHUserCertificateError.invalidFormat }
case .rsa:
guard let exponent = readSSHField(blob, offset: &offset), !exponent.isEmpty,
let modulus = readSSHField(blob, offset: &offset), !modulus.isEmpty
else { throw SSHUserCertificateError.invalidFormat }
case .ecdsa256, .ecdsa384, .ecdsa521, .securityKey:
let curve = algorithm == .securityKey ? "nistp256" : String(algorithm.rawValue.dropFirst(11))
let length = algorithm == .ecdsa521 ? 133 : algorithm == .ecdsa384 ? 97 : 65
guard readSSHField(blob, offset: &offset) == Data(curve.utf8),
let point = readSSHField(blob, offset: &offset), point.count == length, point.first == 4
else { throw SSHUserCertificateError.invalidFormat }
if algorithm == .securityKey {
guard let application = readSSHField(blob, offset: &offset), !application.isEmpty else {
throw SSHUserCertificateError.invalidFormat
}
}
case .unknown: throw SSHUserCertificateError.invalidFormat
}
guard offset == blob.count else { throw SSHUserCertificateError.invalidFormat }
return SSHKeyMaterial(
privateKey: "", publicKey: text, algorithm: algorithm,
fingerprint: SSHWireEncoding.fingerprint(of: blob),
keySize: inferredKeySize(for: algorithm, publicBlob: blob))
}
private static var invalidEnvelope: SSHKeyMaterialError {
.invalidPrivateKey("Invalid private key envelope.")
}
}
@@ -232,10 +232,11 @@ public nonisolated struct SSHKeyMaterial: Sendable {
}
}
private static func inferredKeySize(
static func inferredKeySize(
for algorithm: SSHKeyAlgorithm, publicBlob: Data
) -> Int? {
switch algorithm {
case .unknown: nil
case .ed25519: 256
case .securityKey, .ecdsa256: 256
case .ecdsa384: 384
@@ -255,13 +256,18 @@ public nonisolated struct SSHKeyMaterial: Sendable {
return (bytes.count - 1) * 8 + (8 - first.leadingZeroBitCount)
}
private static func readSSHField(_ data: Data, offset: inout Int) -> Data? {
guard offset + 4 <= data.count else { return nil }
let length = data[offset..<(offset + 4)].reduce(0) { ($0 << 8) | Int($1) }
offset += 4
guard length >= 0, offset + length <= data.count else { return nil }
defer { offset += length }
return data[offset..<(offset + length)]
static func readSSHField(_ data: Data, offset: inout Int) -> Data? {
// Data slices retain their original indices. Offsets in SSH blobs are byte
// counts relative to the blob, so translate them from startIndex first.
guard offset >= 0, offset <= data.count, data.count - offset >= 4 else { return nil }
let headerStart = data.index(data.startIndex, offsetBy: offset)
let payloadStart = data.index(headerStart, offsetBy: 4)
let length = data[headerStart..<payloadStart].reduce(0) { ($0 << 8) | Int($1) }
let payloadOffset = offset + 4
guard length <= data.count - payloadOffset else { return nil }
let payloadEnd = data.index(payloadStart, offsetBy: length)
offset = payloadOffset + length
return data.subdata(in: payloadStart..<payloadEnd)
}
private static func normalizedPassphrase(_ value: String?) -> String? {
+1 -3
View File
@@ -190,9 +190,7 @@ final class MobileAppModel {
let background = dependencies.background
let workspaceConnectionServices = dependencies.workspaceConnections
let connectionCoordinator = dependencies.connectionCoordinator
keyExportServices = KeyExportServices { keys, configuration in
try await connectionCoordinator.installAuthorizedKeys(keys, using: configuration)
}
keyExportServices = connectionCoordinator.keyExportServices()
activateWorkspaceSession = dependencies.activateWorkspaceSession
closeWorkspaceSession = dependencies.closeWorkspaceSession
self.background = background
@@ -1,15 +0,0 @@
import Foundation
import TermindApplication
import TermindPresentation
extension KeyExportModel {
var summary: LocalizedStringResource {
let failures = states.values.filter { if case .failed = $0 { true } else { false } }.count
if failures > 0 {
return AppStrings.Keychain.exportedFailed(count1: states.count - failures, failures: failures)
}
return states.count == 1
? AppStrings.Keychain.exportedTo1Host : AppStrings.Keychain.exportedToHosts(states.count)
}
}
@@ -40,11 +40,17 @@ final class MobileKeyEditorModel: Identifiable {
var confirmation = ""
var privateKey = "" {
didSet {
if canEditPrivateKey, !isLoadingPrivateKey, !originalPrivateKey.isEmpty,
privateKey != originalPrivateKey, publicKey == original?.publicKey
{
publicKey = ""
}
if privateKey != originalPrivateKey, oldValue == originalPrivateKey, !originalPassphrase.isEmpty {
passphrase = ""
}
}
}
var publicKey: String
var passphrase = ""
var savePassphrase = true
private var originalPassphrase = ""
@@ -72,6 +78,7 @@ final class MobileKeyEditorModel: Identifiable {
label = key?.label ?? ""
storage = key?.storage ?? defaultStorage
certificate = key?.certificate ?? ""
publicKey = key?.publicKey ?? ""
colorTag = key?.colorTag ?? .none
let availability = key.map { credentials.keyAvailability(for: $0) }
let keyIsAvailable = availability == .some(.available)
@@ -97,7 +104,8 @@ final class MobileKeyEditorModel: Identifiable {
var isDirty: Bool {
usesWebAuthn || !securityKeyReference.isEmpty || verification != .preferred || label != (original?.label ?? "")
|| storage != (original?.storage ?? .iCloud)
|| certificate != (original?.certificate ?? "") || privateKey != originalPrivateKey
|| publicKey != (original?.publicKey ?? "") || certificate != (original?.certificate ?? "")
|| privateKey != originalPrivateKey
|| passphrase != originalPassphrase
|| savePassphrase != originalSavePassphrase
|| algorithm != .ed25519 || requiresUserPresence || colorTag != (original?.colorTag ?? .none)
@@ -145,7 +153,7 @@ final class MobileKeyEditorModel: Identifiable {
guard !certificate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
do {
return try SSHUserCertificate.parse(
certificate, matching: inspectedMaterial?.publicKey ?? original?.publicKey
certificate, matching: publicKey.isEmpty ? nil : publicKey
).validityWarning
} catch {
return error.localizedDescription
@@ -158,8 +166,7 @@ final class MobileKeyEditorModel: Identifiable {
case .rsa: .rsa(bits: rsaBits)
}
}
var publicKey: String? { inspectedMaterial?.publicKey ?? original?.publicKey }
var fingerprint: String? { inspectedMaterial?.fingerprint ?? original?.fingerprint }
var fingerprint: String? { (try? SSHKeyMaterial.publicMaterial(publicKey))?.fingerprint }
var isEnclave: Bool { original?.storage == .secureEnclave }
var canSelectThisDevice: Bool { original?.storage != .iCloud }
var canUseEnclave: Bool { SecureEnclaveKey.isAvailable }
@@ -167,7 +174,7 @@ final class MobileKeyEditorModel: Identifiable {
guard let original, let inspectedMaterial else { return false }
return inspectedMaterial.publicKey != original.publicKey
}
var validationToken: String { privateKey + "\u{0}" + passphrase }
var validationToken: String { privateKey + "\u{0}" + publicKey }
func loadPrivateKey() async {
guard mode == .edit, let original, original.storage != .secureEnclave, !didLoadPrivateKey else { return }
@@ -195,32 +202,12 @@ final class MobileKeyEditorModel: Identifiable {
}
}
func derivePublicMaterial() async {
func refreshPublicMaterial() async {
guard storage != .secureEnclave, !privateKey.isEmpty else {
inspectedMaterial = nil
return
}
if mode == .edit, privateKey == originalPrivateKey, passphrase.isEmpty, let original {
inspectedMaterial = SSHKeyMaterial(
privateKey: privateKey, publicKey: original.publicKey, algorithm: original.algorithm,
fingerprint: original.fingerprint, keySize: original.keySize
)
return
}
do {
try await Task.sleep(for: .milliseconds(250))
let privateKey = privateKey
let passphrase = passphrase.isEmpty ? nil : passphrase
let comment = resolvedLabel
let material = try await Task.detached(priority: .utility) {
try SSHKeyMaterial.inspect(privateKey: privateKey, passphrase: passphrase, comment: comment)
}.value
try Task.checkCancellation()
inspectedMaterial = material
} catch is CancellationError {
} catch {
inspectedMaterial = nil
}
inspectedMaterial = try? SSHKeyMaterial.imported(privateKey: privateKey, publicKey: publicKey)
}
func loadFile(_ url: URL) async throws {
@@ -243,7 +230,7 @@ final class MobileKeyEditorModel: Identifiable {
try Task.checkCancellation()
guard let certificate = result.certificate else { throw SoftwareKeyFileImporter.unexpectedFileError() }
_ = try SSHUserCertificate.parse(
certificate, matching: inspectedMaterial?.publicKey ?? original?.publicKey)
certificate, matching: publicKey.isEmpty ? nil : publicKey)
self.certificate = certificate
}
@@ -282,31 +269,24 @@ final class MobileKeyEditorModel: Identifiable {
if !canEditPrivateKey {
key.label = label.trimmingCharacters(in: .whitespacesAndNewlines)
key.colorTag = colorTag
if publicKey != key.publicKey {
let material = publicKey.isEmpty ? nil : try SSHKeyMaterial.publicMaterial(publicKey)
key.publicKey = material?.publicKey ?? ""
key.fingerprint = material?.fingerprint ?? ""
key.algorithm = material?.algorithm ?? key.algorithm
key.keySize = material?.keySize ?? key.keySize
}
key.certificate = certificate.trimmingCharacters(in: .whitespacesAndNewlines)
try credentials.upsert(key)
return
}
let input = privateKey
let enteredPassphrase = passphrase.isEmpty ? nil : passphrase
let keepsOriginalPrivateKey = input == originalPrivateKey
let material: SSHKeyMaterial
if keepsOriginalPrivateKey {
material = SSHKeyMaterial(
privateKey: input, publicKey: key.publicKey, algorithm: key.algorithm,
fingerprint: key.fingerprint, keySize: key.keySize
)
} else {
let comment = resolvedLabel
material = try await Task.detached(priority: .userInitiated) {
try SSHKeyMaterial.inspect(
privateKey: input, passphrase: enteredPassphrase, comment: comment
)
}.value
try Task.checkCancellation()
}
let material = try SSHKeyMaterial.imported(privateKey: input, publicKey: publicKey)
let normalizedCertificate = certificate.trimmingCharacters(in: .whitespacesAndNewlines)
if !normalizedCertificate.isEmpty {
_ = try SSHUserCertificate.parse(normalizedCertificate, matching: material.publicKey)
_ = try SSHUserCertificate.parse(
normalizedCertificate, matching: material.publicKey.isEmpty ? nil : material.publicKey)
}
key.label = label.trimmingCharacters(in: .whitespacesAndNewlines)
key.colorTag = colorTag
@@ -316,7 +296,7 @@ final class MobileKeyEditorModel: Identifiable {
key.certificate = normalizedCertificate
key.fingerprint = material.fingerprint
key.keySize = material.keySize
key.hasPassphrase = enteredPassphrase != nil || (keepsOriginalPrivateKey && key.hasPassphrase)
key.hasPassphrase = try SSHKeyMaterial.privateKeyMetadata(input).isEncrypted
try credentials.upsert(
key,
credential: .software(
@@ -347,24 +327,27 @@ final class MobileKeyEditorModel: Identifiable {
let cipher = cipher
let rounds = rounds
let generates = mode == .generate
let publicKey = publicKey
let material = try await Task.detached(priority: .userInitiated) {
if generates {
return try SSHKeyMaterial.generate(
generation, comment: comment, passphrase: passphrase, cipher: cipher, rounds: rounds)
}
return try SSHKeyMaterial.inspect(privateKey: input, passphrase: passphrase, comment: comment)
return try SSHKeyMaterial.imported(privateKey: input, publicKey: publicKey)
}.value
try Task.checkCancellation()
let normalizedCertificate = certificate.trimmingCharacters(in: .whitespacesAndNewlines)
if !normalizedCertificate.isEmpty {
_ = try SSHUserCertificate.parse(normalizedCertificate, matching: material.publicKey)
_ = try SSHUserCertificate.parse(
normalizedCertificate, matching: material.publicKey.isEmpty ? nil : material.publicKey)
}
key = SSHKeyRef(
label: label.trimmingCharacters(in: .whitespacesAndNewlines),
algorithm: material.algorithm, publicKey: material.publicKey,
certificate: normalizedCertificate, fingerprint: material.fingerprint,
credentialRef: "termind.key.\(UUID())", storage: storage, keySize: material.keySize,
hasPassphrase: passphrase != nil, createdAt: .now, colorTag: colorTag)
hasPassphrase: try SSHKeyMaterial.privateKeyMetadata(material.privateKey).isEncrypted, createdAt: .now,
colorTag: colorTag)
secret = .software(
SSHPrivateKeySecret(privateKey: material.privateKey, passphrase: savePassphrase ? passphrase : nil))
}
@@ -35,25 +35,24 @@ struct MobileCredentialsView: View {
MobileCardGrid {
ForEach(keys) { key in
MobileActionCard {
if !key.publicKey.isEmpty { exportingKey = key }
exportingKey = key
} content: {
MobileKeyCredentialCard(key: key, credentials: app.credentials)
} actions: {
Button(AppStrings.Keychain.exportToHosts.swiftUIKey, systemImage: "square.and.arrow.up")
Button(AppStrings.Keychain.exportToHost.swiftUIKey, systemImage: "square.and.arrow.up")
{
exportingKey = key
}
.disabled(key.publicKey.isEmpty)
Divider()
Menu(AppStrings.Common.copy.swiftUIKey, systemImage: "doc.on.doc") {
Button(AppStrings.Keychain.copyPublicKey.swiftUIKey, systemImage: "doc.on.doc") {
Button(AppStrings.Keychain.publicKey.swiftUIKey, systemImage: "doc.on.doc") {
UIPasteboard.general.string = key.publicKey
}
.disabled(key.publicKey.isEmpty)
Button(AppStrings.KnownHosts.copyFingerprint.swiftUIKey, systemImage: "doc.on.doc")
{
Button(AppStrings.KnownHosts.fingerprint.swiftUIKey, systemImage: "doc.on.doc") {
UIPasteboard.general.string = key.fingerprint
}
.disabled(key.publicKey.isEmpty || key.fingerprint.isEmpty)
}
Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") {
if key.algorithm == .securityKey {
@@ -123,10 +122,10 @@ struct MobileCredentialsView: View {
.sheet(item: $editingSecurityKey) { key in SecurityKeyEditor(credentials: app.credentials, existing: key) }
.sheet(item: $editing) { MobileIdentityEditor(app: app, identity: $0) }
.sheet(item: $keyEditor) { MobileKeyEditor(model: $0) }
.sheet(item: $exportingKey) { key in
MobileKeyExportSheet(
library: app.library, credentials: app.credentials,
model: KeyExportModel(keys: [key], credentials: app.credentials, services: app.keyExportServices))
.navigationDestination(item: $exportingKey) { key in
MobileKeyExportDetailView(
library: app.library,
model: KeyExportModel(key: key, library: app.library, services: app.keyExportServices))
}
.confirmationDialog(
AppStrings.Keychain.deleteIdentityConfirmation,
@@ -179,8 +178,11 @@ private struct MobileKeyCredentialCard: View {
MobileCardIcon(symbol: "key.fill", tint: key.colorTag.color ?? .accentColor)
VStack(alignment: .leading, spacing: 6) {
Text(key.label).font(.headline).lineLimit(1)
Text(key.fingerprint).font(.caption.monospaced()).foregroundStyle(.secondary)
.lineLimit(1).truncationMode(.middle)
Text(
key.algorithm == .unknown
? AppStrings.Keychain.unknownKeyType : AppStrings.Keychain.keyType(keyDisplayName)
).font(.caption).foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -197,7 +199,6 @@ private struct MobileKeyCredentialCard: View {
}
@ViewBuilder private var primaryChips: some View {
MobileCardChip(text: keyDisplayName)
if key.hasPassphrase {
MobileCardChip(
title: credentials.hasSavedPassphrase(for: key)
+8 -3
View File
@@ -156,6 +156,11 @@ struct MobileKeyEditor: View {
}
}
if !model.usesWebAuthn && model.mode != .generate && model.storage != .secureEnclave {
Section(AppStrings.Keychain.publicKey.swiftUIKey) {
TextEditor(text: $model.publicKey).font(.caption.monospaced()).frame(minHeight: 90)
.textInputAutocapitalization(.never).autocorrectionDisabled()
Text(AppStrings.KeyExport.optionalPublicKey).font(.caption).foregroundStyle(.secondary)
}
Section(AppStrings.Keychain.sshCertificate.swiftUIKey) {
Button(AppStrings.Keychain.chooseFile.swiftUIKey, systemImage: "folder") {
choosingCertificate = true
@@ -169,9 +174,9 @@ struct MobileKeyEditor: View {
if !model.usesWebAuthn, let warning = model.certificateWarning {
Text(warning).font(.caption).foregroundStyle(.orange)
}
if !model.usesWebAuthn, let publicKey = model.publicKey {
if !model.usesWebAuthn, model.storage == .secureEnclave, !model.publicKey.isEmpty {
Section(AppStrings.Keychain.publicKey.swiftUIKey) {
Text(publicKey).font(.caption.monospaced()).textSelection(.enabled)
Text(model.publicKey).font(.caption.monospaced()).textSelection(.enabled)
}
}
if !model.usesWebAuthn, let fingerprint = model.fingerprint {
@@ -208,7 +213,7 @@ struct MobileKeyEditor: View {
model.clearSensitiveDrafts()
}
.task { await model.loadPrivateKey() }
.task(id: model.validationToken) { await model.derivePublicMaterial() }
.task(id: model.validationToken) { await model.refreshPublicMaterial() }
}
private func receiveReference(_ providers: [NSItemProvider]) -> Bool {
@@ -0,0 +1,58 @@
import SwiftUI
import TermindApplication
import TermindPresentation
struct MobileKeyExportDetailView: View {
let library: HostLibraryModel
@State var model: KeyExportModel
@Environment(\.dismiss) private var dismiss
var body: some View {
Group {
if model.journey.isPresented {
journey
} else {
KeyExportForm(model: model)
}
}
.navigationTitle(AppStrings.Keychain.exportToHost.swiftUIKey)
.toolbar {
ToolbarItem(placement: .primaryAction) {
if model.didExport {
Button(AppStrings.Common.done.swiftUIKey) { dismiss() }
} else if case .failed = model.phase, model.journey.isPresented {
Button(AppStrings.Common.edit.swiftUIKey) { model.editOptions() }
} else if !model.journey.isPresented {
Button(AppStrings.KeyExport.exportAndAttach.swiftUIKey) { model.start() }
.disabled(!model.canStart)
} else if model.phase == .exporting {
ProgressView().accessibilityLabel(AppStrings.KeyExport.exporting.swiftUIKey)
}
}
}
.onDisappear { model.cancel() }
}
private var journey: some View {
let promptID = model.authenticationPrompt?.id
let hostKey = model.connection.pendingHostKey
let operationID = model.operationID
return MobileConnectionJourneyView(
journey: model.journey, library: library, authenticationPrompt: model.authenticationPrompt,
hostKey: hostKey, reconnectState: nil,
submit: { identity, username, password, passphrase, key, save in
guard let promptID else { return }
model.submitAuthentication(
promptID: promptID, identityID: identity, username: username, password: password,
passphrase: passphrase, keyID: key, save: save)
},
accept: { remember in
guard let hostKey else { return }
model.acceptHostKey(hostKey.observation, operationID: operationID, remember: remember)
},
retry: model.retry, retryReconnectNow: {}, stopReconnecting: {}, cancel: { dismiss() },
securityKeySigning: model.connection.securityKeySigning,
keyboardAuthentication: model.connection.keyboardAuthentication,
cancelKeyboardAuthentication: { dismiss() })
}
}
@@ -1,13 +0,0 @@
import SwiftUI
import TermindApplication
struct MobileKeyExportSheet: View {
let library: HostLibraryModel
let credentials: CredentialLibraryModel
@State var model: KeyExportModel
@Environment(\.dismiss) private var dismiss
var body: some View {
MobileMultiHostPicker(library: library, credentials: credentials, exporting: model, close: { dismiss() })
}
}
@@ -1,75 +0,0 @@
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
extension MobileMultiHostPicker where Header == MobileKeyExportHeader, RowDetail == MobileKeyExportHostDetail {
init(
library: HostLibraryModel, credentials: CredentialLibraryModel,
exporting model: KeyExportModel, close: @escaping () -> Void
) {
self.init(
library: library, useCase: .keyExport, selected: [],
title: AppStrings.Keychain.exportToHosts,
actionTitle: model.isFinished ? AppStrings.Common.done : AppStrings.Common.export,
emptyDescription: AppStrings.Keychain.addAndConnectToAHostBeforeExportingAKeyToIt,
requiresSelection: !model.isFinished,
isSelectable: { credentials.keyExportUnavailableReason(for: $0) == nil },
interaction: MobileMultiHostPickerInteraction(
allowsSelection: !model.isExporting && !model.isFinished,
allowsDismissal: !model.isExporting,
allowsConfirmation: !model.isExporting,
dismissesOnSave: false),
displayedHosts: model.isExporting || model.isFinished ? model.targets : nil,
selectionChanged: model.updateSelection,
save: { hostIDs in
if model.isFinished {
close()
} else {
Task { await model.export(to: hostIDs) }
}
},
header: { MobileKeyExportHeader(model: model) },
rowDetail: { host in
MobileKeyExportHostDetail(
state: model.states[host.id], unavailable: credentials.keyExportUnavailableReason(for: host))
})
}
}
struct MobileKeyExportHeader: View {
let model: KeyExportModel
var body: some View {
Section {
if let key = model.keys.first { Label(key.label, systemImage: "key") }
if model.isFinished {
Text(model.summary).accessibilityIdentifier("key-export.summary")
} else {
Text(AppStrings.Common.selected(model.selected.count)).foregroundStyle(.secondary)
}
}
}
}
struct MobileKeyExportHostDetail: View {
let state: KeyExportModel.ExportState?
let unavailable: String?
var body: some View {
switch state {
case .running:
ProgressView()
case .succeeded:
Label(AppStrings.Keychain.exported.swiftUIKey, systemImage: "checkmark.circle.fill")
.font(.caption).foregroundStyle(.secondary)
case .failed(let message):
Label(message, systemImage: "exclamationmark.triangle")
.font(.caption).foregroundStyle(.red)
case nil:
if let unavailable {
Text(unavailable).font(.caption).foregroundStyle(.secondary)
}
}
}
}
@@ -147,7 +147,7 @@ struct MobileKnownHostEditor: View {
}
Section(AppStrings.KnownHosts.hostKey.swiftUIKey) {
Picker(AppStrings.KnownHosts.keyType.swiftUIKey, selection: $draft.algorithm) {
ForEach(SSHKeyAlgorithm.allCases) { Text($0.displayName).tag($0) }
ForEach(SSHKeyAlgorithm.allCases.filter { $0 != .unknown }) { Text($0.displayName).tag($0) }
}
LabeledContent(AppStrings.KnownHosts.fingerprint.swiftUIKey) {
TextField(
+24 -63
View File
@@ -14,95 +14,56 @@ import UIKit
)
@MainActor
struct MobileKeyExportRenderingTests {
@Test func exportUsesTheNativeHostPickerAcrossSubmissionStates() async throws {
@Test func exportUsesANativeDetailAndShowsTheKeyPassphraseInTheForm() async throws {
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
for language in ["en", "zh-Hans"] {
let fixture = KeyExportFixture()
let recorder = KeyExportRecorder(failingHost: fixture.alpha.id, blocks: true)
let model = fixture.model(recorder)
let presentation = KeyExportRenderingPresentation()
let fixture = try MobileExportFixture(encrypted: true)
let model = fixture.model()
model.hostID = fixture.host.id
let previous = scene.keyWindow
let window = UIWindow(windowScene: scene)
let controller = UIHostingController(
rootView: KeyExportRenderingHost(fixture: fixture, model: model, presentation: presentation)
.environment(\.locale, Locale(identifier: language)))
rootView:
NavigationStack {
MobileKeyExportDetailView(library: fixture.library, model: model)
}.environment(\.locale, Locale(identifier: language)))
window.rootViewController = controller
window.windowLevel = .alert + 1
window.makeKeyAndVisible()
defer {
presentation.isPresented = false
window.isHidden = true
window.rootViewController = nil
previous?.makeKey()
}
try await Task.sleep(for: .milliseconds(700))
presentation.isPresented = true
try await capture(window, language: language, state: "selecting")
let sheet = try #require(controller.presentedViewController)
let navigation = try #require(descendant(UINavigationController.self, in: sheet))
let confirm = try #require(confirmation(in: navigation))
#expect(!confirm.isEnabled)
if UIDevice.current.userInterfaceIdiom == .pad {
let frame = try #require(sheet.presentationController?.frameOfPresentedViewInContainerView)
#expect(abs(frame.midX - window.bounds.midX) < 2)
#expect(abs(frame.midY - window.bounds.midY) < 2)
}
let export = Task { await model.export(to: [fixture.alpha.id]) }
await recorder.waitUntilStarted()
try await capture(window, language: language, state: "running")
#expect(try !#require(confirmation(in: navigation)).isEnabled)
await recorder.resume()
await export.value
#expect(model.isFinished)
try await capture(window, language: language, state: "failed")
#expect(try #require(confirmation(in: navigation)).isEnabled)
try await Task.sleep(for: .milliseconds(800))
#expect(controller.presentedViewController == nil)
#expect(model.needsExportPassphrase && !model.canStart && !model.journey.isPresented)
try capture(window, language: language)
let passphraseField = try #require(
descendants(window).compactMap { $0 as? UITextField }.first { $0.isSecureTextEntry })
#expect(passphraseField.becomeFirstResponder())
try await Task.sleep(for: .milliseconds(400))
try capture(window, language: language, state: "keyboard")
window.endEditing(true)
model.exportPassphrase = "secret"
#expect(model.canStart)
}
}
private func descendant<T: UIViewController>(_ type: T.Type, in controller: UIViewController) -> T? {
if let result = controller as? T { return result }
return controller.children.lazy.compactMap { descendant(type, in: $0) }.first
private func descendants(_ view: UIView) -> [UIView] {
[view] + view.subviews.flatMap(descendants)
}
private func confirmation(in navigation: UINavigationController) -> UIBarButtonItem? {
guard let item = navigation.topViewController?.navigationItem else { return nil }
let buttons = item.trailingItemGroups.flatMap(\.barButtonItems) + (item.rightBarButtonItems ?? [])
return buttons.first { $0.accessibilityIdentifier == "host-picker.confirm" } ?? buttons.first
}
private func capture(_ window: UIWindow, language: String, state: String) async throws {
private func capture(_ window: UIWindow, language: String, state: String = "form") throws {
window.layoutIfNeeded()
try await Task.sleep(for: .milliseconds(800))
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
}
let data = try #require(image.pngData())
let directory = FileManager.default.temporaryDirectory.appending(path: "key-export-screenshots")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let device = UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone"
let output = directory.appending(path: "\(device)-\(language)-\(state).png")
try data.write(to: output)
try #require(image.pngData()).write(to: output)
print("Key export snapshot: \(output.path)")
}
}
@MainActor @Observable
private final class KeyExportRenderingPresentation {
var isPresented = false
}
private struct KeyExportRenderingHost: View {
let fixture: KeyExportFixture
let model: KeyExportModel
@Bindable var presentation: KeyExportRenderingPresentation
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
Color.clear
.sheet(isPresented: $presentation.isPresented) {
MobileKeyExportSheet(library: fixture.library, credentials: fixture.credentials, model: model)
}
// Match the presenting-window environment established by MobileRootView.
.environment(\.mobileLayout, MobileLayout(sizeClass: sizeClass))
}
}
+81 -95
View File
@@ -1,113 +1,99 @@
import Foundation
import TermindApplication
import TermindDomain
import TermindRemoteInfrastructure
import Testing
@testable import TermindIOS
@Suite @MainActor
struct MobileKeyExportTests {
@Test func searchAndSelectAllOnlyChangeVisibleEligibleHosts() {
let fixture = KeyExportFixture()
let model = MobileMultiHostPickerModel(
library: fixture.library, useCase: .keyExport, selected: [],
isSelectable: { fixture.credentials.keyExportUnavailableReason(for: $0) == nil })
#expect(model.hosts.count == 3)
model.toggle(fixture.untrusted.id)
#expect(model.selected.isEmpty)
#expect(!model.isSelectable(fixture.untrusted))
model.query = "Alpha"
model.toggleAllResults()
#expect(model.selected == [fixture.alpha.id])
#expect(model.allResultsSelected)
model.query = "Beta"
model.toggleAllResults()
#expect(model.selected == [fixture.alpha.id, fixture.beta.id])
model.toggleAllResults()
#expect(model.selected == [fixture.alpha.id])
model.query = "no match"
model.toggleAllResults()
#expect(model.selected == [fixture.alpha.id])
#expect(!model.allResultsSelected)
@Test func encryptedImportCanSaveWithoutPassphraseOrPublicKey() async throws {
let library = HostLibraryModel()
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: "secret")
let editor = MobileKeyEditorModel(mode: .importKey, credentials: CredentialLibraryModel(library: library))
editor.privateKey = material.privateKey
#expect(editor.canSave)
try await editor.save()
let key = try #require(library.keys.first)
#expect(key.hasPassphrase && key.publicKey.isEmpty && key.fingerprint.isEmpty)
#expect(key.algorithm == .unknown)
#expect(library.requiresPassphraseEntry(for: key))
let export = KeyExportModel(
key: key, library: library, services: RemoteConnectionCoordinator().keyExportServices())
#expect(export.needsExportPassphrase && export.authenticationPrompt == nil)
}
@Test func manuallyEnteredPublicKeyCanBeChangedAndClearedWithoutUnlocking() async throws {
let library = HostLibraryModel()
let credentials = CredentialLibraryModel(library: library)
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: "secret")
let importer = MobileKeyEditorModel(mode: .importKey, credentials: credentials)
importer.privateKey = material.privateKey
importer.publicKey = material.publicKey
try await importer.save()
let original = try #require(library.keys.first)
#expect(original.publicKey == material.publicKey && original.fingerprint == material.fingerprint)
let export = KeyExportModel(
key: original, library: library, services: RemoteConnectionCoordinator().keyExportServices())
#expect(!export.needsExportPassphrase)
let editor = MobileKeyEditorModel(mode: .edit, credentials: credentials, key: original)
await editor.loadPrivateKey()
editor.publicKey = "malformed"
await #expect(throws: (any Error).self) { try await editor.save() }
#expect(library.key(original.id) == original)
editor.publicKey = ""
try await editor.save()
let cleared = try #require(library.key(original.id))
#expect(cleared.publicKey.isEmpty && cleared.fingerprint.isEmpty && cleared.hasPassphrase)
}
@Test func typedPassphraseDoesNotMakeAnUnencryptedKeyEncrypted() async throws {
let library = HostLibraryModel()
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
let editor = MobileKeyEditorModel(mode: .importKey, credentials: CredentialLibraryModel(library: library))
editor.privateKey = material.privateKey
editor.passphrase = "not-encryption"
try await editor.save()
#expect(library.keys.first?.hasPassphrase == false)
}
@Test func replacingPrivateKeyPreservesANewManuallyEnteredPublicKey() async throws {
let library = HostLibraryModel()
let credentials = CredentialLibraryModel(library: library)
let generator = MobileKeyEditorModel(mode: .generate, credentials: credentials)
try await generator.save()
let original = try #require(library.keys.first)
let replacement = try SSHKeyMaterial.generate(.ed25519, comment: "replacement", passphrase: "secret")
let editor = MobileKeyEditorModel(mode: .edit, credentials: credentials, key: original)
await editor.loadPrivateKey()
editor.publicKey = replacement.publicKey
editor.privateKey = replacement.privateKey
#expect(editor.publicKey == replacement.publicKey)
try await editor.save()
let saved = try #require(library.key(original.id))
#expect(saved.publicKey == replacement.publicKey && saved.fingerprint == replacement.fingerprint)
#expect(saved.hasPassphrase && library.requiresPassphraseEntry(for: saved))
}
}
@MainActor
struct KeyExportFixture {
let key = SSHKeyRef(label: "Deployment Key", publicKey: "ssh-ed25519 fixture-public-key")
let alpha: TermindDomain.Host
let beta: TermindDomain.Host
let untrusted: TermindDomain.Host
struct MobileExportFixture {
let key: SSHKeyRef
let library: HostLibraryModel
let credentials: CredentialLibraryModel
init() {
let identity = Identity(name: "Deployment", username: "deploy")
alpha = TermindDomain.Host(name: "Alpha", host: "alpha.example.invalid", identityID: identity.id)
beta = TermindDomain.Host(name: "Beta", host: "beta.example.invalid", identityID: identity.id)
untrusted = TermindDomain.Host(name: "Untrusted", host: "untrusted.example.invalid", identityID: identity.id)
var telnet = TermindDomain.Host(name: "Telnet", host: "telnet.example.invalid", identityID: identity.id)
telnet.sshEnabled = false
telnet.telnetEnabled = true
library = HostLibraryModel(
hosts: [alpha, beta, untrusted, telnet], identities: [identity],
knownHosts: [alpha, beta].map { KnownHostEntry(hostname: $0.host, fingerprint: "SHA256:fixture") })
credentials = CredentialLibraryModel(library: library)
let host: TermindDomain.Host
init(encrypted: Bool = false) throws {
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: encrypted ? "secret" : nil)
key = SSHKeyRef(
label: "Deployment Key", algorithm: encrypted ? .unknown : material.algorithm,
publicKey: encrypted ? "" : material.publicKey,
credentialRef: "key", hasPassphrase: encrypted)
let identity = Identity(name: "Deploy", username: "deploy")
host = TermindDomain.Host(name: "Production", host: "server.example.com", identityID: identity.id)
library = HostLibraryModel(hosts: [host], keys: [key], identities: [identity])
try library.upsert(key, credential: .software(SSHPrivateKeySecret(privateKey: material.privateKey)))
}
func model(_ recorder: KeyExportRecorder) -> KeyExportModel {
KeyExportModel(
keys: [key], credentials: credentials, services: KeyExportServices(install: recorder.install))
}
}
actor KeyExportRecorder {
struct Call: Sendable {
let hostID: UUID
let publicKeys: [String]
let names: [String]
let username: String
let trustedKeys: Set<SSHHostKeyIdentity>
}
private(set) var calls: [Call] = []
private let failingHost: UUID?
private let blocks: Bool
private var started: CheckedContinuation<Void, Never>?
private var release: CheckedContinuation<Void, Never>?
init(failingHost: UUID? = nil, blocks: Bool = false) {
self.failingHost = failingHost
self.blocks = blocks
}
func install(_ keys: [SSHAuthorizedKeyInstall], _ configuration: SSHConnectionConfiguration) async throws {
calls.append(
Call(
hostID: configuration.hostID, publicKeys: keys.map(\.publicKey), names: keys.map(\.name),
username: configuration.username, trustedKeys: configuration.expectedHostKeys))
if blocks {
await withCheckedContinuation { continuation in
release = continuation
started?.resume()
started = nil
}
}
if configuration.hostID == failingHost { throw ExportFailure() }
}
func waitUntilStarted() async {
if !calls.isEmpty { return }
await withCheckedContinuation { started = $0 }
}
func resume() {
release?.resume()
release = nil
}
private struct ExportFailure: LocalizedError {
var errorDescription: String? { "Fixture export failure" }
func model() -> KeyExportModel {
KeyExportModel(key: key, library: library, services: RemoteConnectionCoordinator().keyExportServices())
}
}
+13 -7
View File
@@ -215,37 +215,40 @@ struct MobileVaultFeatureTests {
try await generator.save()
let generated = try #require(app.credentials.keys.first)
#expect(generated.label == "ECDSA")
#expect(!generated.publicKey.isEmpty && !generated.fingerprint.isEmpty)
let replacement = try SSHKeyMaterial.generate(.ed25519, comment: "fixture", passphrase: nil)
let importer = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials)
#expect(!importer.canSave)
#expect(importer.defaultName == "")
importer.privateKey = replacement.privateKey
await importer.derivePublicMaterial()
await importer.refreshPublicMaterial()
#expect(importer.defaultName == "Ed25519")
#expect(importer.canSave)
try await importer.save()
let imported = try #require(app.credentials.keys.first { $0.id != generated.id })
#expect(imported.label == "Ed25519")
#expect(imported.publicKey.isEmpty && imported.fingerprint.isEmpty)
let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: generated)
await editor.loadPrivateKey()
editor.label = ""
editor.privateKey = replacement.privateKey
#expect(editor.defaultName == "")
await editor.derivePublicMaterial()
await editor.refreshPublicMaterial()
#expect(editor.defaultName == "Ed25519")
#expect(editor.canSave)
try await editor.save()
let edited = try #require(app.credentials.key(generated.id))
#expect(edited.label == "Ed25519")
#expect(edited.algorithm == .ed25519)
#expect(edited.publicKey.isEmpty && edited.fingerprint.isEmpty)
let reopened = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: edited)
#expect(reopened.label == "Ed25519")
await reopened.loadPrivateKey()
reopened.privateKey = "invalid replacement"
#expect(reopened.defaultName == "")
await reopened.derivePublicMaterial()
await reopened.refreshPublicMaterial()
#expect(reopened.defaultName == "")
reopened.privateKey = ""
#expect(reopened.defaultName == "")
@@ -534,7 +537,7 @@ struct MobileVaultFeatureTests {
return
}
#expect(importedSecret.passphrase == nil && imported.hasPassphrase)
#expect(imported.publicKey == generated.publicKey)
#expect(imported.publicKey.isEmpty)
}
@Test func replacingPrivateKeyDoesNotReuseItsSavedPassphrase() async throws {
@@ -581,7 +584,7 @@ struct MobileVaultFeatureTests {
let invalid = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials)
invalid.label = "Invalid"
invalid.privateKey = secret.privateKey
invalid.privateKey = "not a private key"
invalid.passphrase = "wrong"
await #expect(throws: (any Error).self) { try await invalid.save() }
#expect(app.credentials.keys.count == 1)
@@ -592,7 +595,8 @@ struct MobileVaultFeatureTests {
importer.passphrase = "test-only-passphrase"
try await importer.save()
#expect(app.credentials.keys.count == 2)
#expect(app.credentials.keys.allSatisfy { $0.fingerprint == key.fingerprint })
let imported = try #require(app.credentials.keys.first { $0.id != key.id })
#expect(imported.publicKey.isEmpty && imported.fingerprint.isEmpty && imported.hasPassphrase)
let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: key)
await editor.loadPrivateKey()
@@ -627,7 +631,9 @@ struct MobileVaultFeatureTests {
let replacement = try SSHKeyMaterial.generate(.rsa(bits: 2048), comment: "replacement", passphrase: nil)
editor.privateKey = replacement.privateKey
await editor.derivePublicMaterial()
#expect(editor.publicKey.isEmpty)
editor.publicKey = replacement.publicKey
await editor.refreshPublicMaterial()
#expect(editor.replacesPublicKey)
#expect(editor.fingerprint == replacement.fingerprint)
try await editor.save()
@@ -0,0 +1,73 @@
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
struct KeyExportDetailView: View {
let keyID: SSHKeyRef.ID
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
@State private var model: KeyExportModel?
var body: some View {
VaultDetailScaffold(
title: AppStrings.Keychain.exportToHost, symbol: "square.and.arrow.up", close: window.closeVaultWork
) {
if let model {
if model.journey.isPresented {
journey(model)
} else {
KeyExportForm(model: model)
}
} else {
ContentUnavailableView(AppStrings.Keychain.keyUnavailable.swiftUIKey, systemImage: "key.slash")
}
} footer: {
HStack {
if model?.phase == .exporting {
ProgressView().controlSize(.small)
Text(AppStrings.KeyExport.exporting).font(.caption)
}
Spacer()
Button(
model?.didExport == true ? AppStrings.Common.done.swiftUIKey : AppStrings.Common.cancel.swiftUIKey,
action: window.closeVaultWork)
if let model, case .failed = model.phase, model.journey.isPresented {
Button(AppStrings.Common.edit.swiftUIKey) { model.editOptions() }
}
if let model, !model.didExport, !model.journey.isPresented {
Button(AppStrings.KeyExport.exportAndAttach.swiftUIKey) { model.start() }
.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
.disabled(!model.canStart)
}
}.padding(Metrics.Space.l)
}
.task(id: keyID) {
model?.cancel()
model = app.hostLibrary.key(keyID).map {
KeyExportModel(key: $0, library: app.hostLibrary, services: app.connections.keyExportServices())
}
}
.onDisappear { model?.cancel() }
}
private func journey(_ model: KeyExportModel) -> some View {
let promptID = model.authenticationPrompt?.id
let operationID = model.operationID
return ConnectionJourneyView(
journey: model.journey, authenticationPrompt: model.authenticationPrompt,
hostKey: model.connection.pendingHostKey,
submitAuthentication: { identity, username, password, passphrase, key, save in
guard let promptID else { return }
model.submitAuthentication(
promptID: promptID, identityID: identity, username: username, password: password,
passphrase: passphrase, keyID: key, save: save)
},
continueHostKey: { model.acceptHostKey($0, operationID: operationID, remember: false) },
trustHostKey: { model.acceptHostKey($0, operationID: operationID, remember: true) },
denyHostKey: window.closeVaultWork, retry: model.retry, cancel: window.closeVaultWork,
securityKeySigning: model.connection.securityKeySigning,
keyboardAuthentication: model.connection.keyboardAuthentication,
cancelKeyboardAuthentication: window.closeVaultWork, isEmbedded: true)
}
}
+54 -392
View File
@@ -275,8 +275,8 @@ struct KeychainPageView: ManagedCardPage {
}
private func requestExport(_ keys: [SSHKeyRef]) {
guard !keys.isEmpty else { return }
window.openVaultWork(.exportKeys(keys.map(\.id)), on: .keychain)
guard keys.count == 1, let key = keys.first else { return }
window.openVaultWork(.exportKey(key.id), on: .keychain)
}
private func requestDelete(_ items: [Item]) {
@@ -450,7 +450,7 @@ struct KeychainImportDetail: View {
algorithm: material.algorithm, publicKey: material.publicKey,
certificate: certificate, fingerprint: material.fingerprint,
credentialRef: "termind.key.\(UUID().uuidString)", storage: storage,
keySize: material.keySize, hasPassphrase: passphrase != nil,
keySize: material.keySize, hasPassphrase: try SSHKeyMaterial.privateKeyMetadata(privateKey).isEncrypted,
createdAt: Date(), colorTag: colorTag
)
try app.credentials.upsert(
@@ -485,7 +485,8 @@ private struct SoftwareKeyEditor: View {
_ name: String, _ colorTag: TagColor, _ storage: KeyStorage, _ privateKey: String,
_ passphrase: String?, _ savePassphrase: Bool, _ certificate: String, _ material: SSHKeyMaterial
) async throws -> SSHKeyRef
let saveMetadata: ((_ name: String, _ colorTag: TagColor, _ certificate: String) throws -> SSHKeyRef)?
let saveMetadata:
((_ name: String, _ colorTag: TagColor, _ certificate: String, _ publicKey: String) throws -> SSHKeyRef)?
let delete: (() throws -> Void)?
@State private var name: String
@@ -493,6 +494,7 @@ private struct SoftwareKeyEditor: View {
@State private var storage: KeyStorage
@State private var privateKey = ""
@State private var originalPrivateKey = ""
@State private var publicKey: String
@State private var passphrase = ""
@State private var originalPassphrase = ""
@State private var savePassphrase = true
@@ -518,7 +520,9 @@ private struct SoftwareKeyEditor: View {
defaultStorage: KeyStorage = .iCloud,
saveSecurityKey: ((String, TagColor, SecurityKeyCredential) throws -> Void)? = nil,
loadPrivateKey: (() throws -> SSHPrivateKeySecret)? = nil,
saveMetadata: ((_ name: String, _ colorTag: TagColor, _ certificate: String) throws -> SSHKeyRef)? = nil,
saveMetadata: (
(_ name: String, _ colorTag: TagColor, _ certificate: String, _ publicKey: String) throws -> SSHKeyRef
)? = nil,
delete: (() throws -> Void)? = nil,
save:
@escaping (
@@ -538,6 +542,7 @@ private struct SoftwareKeyEditor: View {
_colorTag = State(initialValue: existing?.colorTag ?? .none)
_storage = State(initialValue: existing?.storage ?? defaultStorage)
_certificate = State(initialValue: existing?.certificate ?? "")
_publicKey = State(initialValue: existing?.publicKey ?? "")
_isLoading = State(initialValue: loadPrivateKey != nil)
}
@@ -615,10 +620,10 @@ private struct SoftwareKeyEditor: View {
if !usesWebAuthn {
Section(AppStrings.Keychain.publicKey.swiftUIKey) {
Text(publicKeyDisplay)
.font(.caption.monospaced())
.foregroundStyle(material == nil ? .secondary : .primary)
.textSelection(.enabled)
keyTextEditor(
text: $publicKey, minHeight: 100, placeholder: AppStrings.KeyExport.optionalPublicKey
)
.accessibilityLabel(AppStrings.Keychain.publicKey.swiftUIKey)
}
Section {
@@ -703,6 +708,9 @@ private struct SoftwareKeyEditor: View {
}
}
.onChange(of: privateKey) { old, value in
if !isLoading, !originalPrivateKey.isEmpty, value != originalPrivateKey, publicKey == existing?.publicKey {
publicKey = ""
}
if value != originalPrivateKey, old == originalPrivateKey, !originalPassphrase.isEmpty {
passphrase = ""
}
@@ -727,7 +735,7 @@ private struct SoftwareKeyEditor: View {
} message: {
Text(AppStrings.Keychain.deleteKeyWarning)
}
.task(id: validationToken) { await derivePublicMaterial() }
.task(id: validationToken) { refreshPublicMaterial() }
}
/// A key blob field drawn on the form's own background.
@@ -786,11 +794,13 @@ private struct SoftwareKeyEditor: View {
@MainActor
private var isDirty: Bool {
guard let existing else { return !name.isEmpty || !privateKey.isEmpty || !certificate.isEmpty }
guard let existing else {
return !name.isEmpty || !privateKey.isEmpty || !publicKey.isEmpty || !certificate.isEmpty
}
return name != existing.label || colorTag != existing.colorTag || storage != existing.storage
|| privateKey != originalPrivateKey || passphrase != originalPassphrase
|| savePassphrase != originalSavePassphrase
|| certificate != existing.certificate
|| publicKey != existing.publicKey || certificate != existing.certificate
}
private func cancelEditing() {
@@ -815,36 +825,22 @@ private struct SoftwareKeyEditor: View {
return true
}
let normalizedCertificate = certificate.trimmingCharacters(in: .whitespacesAndNewlines)
if !canEditPrivateKey, let existing, let saveMetadata {
if !canEditPrivateKey, existing != nil, let saveMetadata {
if !normalizedCertificate.isEmpty {
_ = try SSHUserCertificate.parse(normalizedCertificate, matching: existing.publicKey)
_ = try SSHUserCertificate.parse(
normalizedCertificate, matching: publicKey.isEmpty ? nil : publicKey)
}
_ = try saveMetadata(
name.trimmingCharacters(in: .whitespacesAndNewlines), colorTag, normalizedCertificate
name.trimmingCharacters(in: .whitespacesAndNewlines), colorTag, normalizedCertificate, publicKey
)
isWorking = false
return true
}
let passphrase = passphrase.isEmpty ? nil : passphrase
let inspected: SSHKeyMaterial
if let existing, privateKey == originalPrivateKey {
inspected = SSHKeyMaterial(
privateKey: privateKey, publicKey: existing.publicKey,
algorithm: existing.algorithm, fingerprint: existing.fingerprint,
keySize: existing.keySize
)
} else {
let keyText = privateKey
let comment = name
inspected = try await Task.detached(priority: .userInitiated) {
try SSHKeyMaterial.inspect(
privateKey: keyText, passphrase: passphrase, comment: comment
)
}.value
}
let inspected = try SSHKeyMaterial.imported(privateKey: privateKey, publicKey: publicKey)
if !normalizedCertificate.isEmpty {
_ = try SSHUserCertificate.parse(
normalizedCertificate, matching: inspected.publicKey
normalizedCertificate, matching: inspected.publicKey.isEmpty ? nil : inspected.publicKey
)
}
_ = try await save(
@@ -869,7 +865,7 @@ private struct SoftwareKeyEditor: View {
}
}
private var validationToken: String { privateKey + "\u{0}" + passphrase }
private var validationToken: String { privateKey + "\u{0}" + publicKey }
private var defaultName: String {
if usesWebAuthn { return String(localized: AppStrings.SecurityKey.title) }
@@ -892,15 +888,11 @@ private struct SoftwareKeyEditor: View {
)
}
private var publicKeyDisplay: String {
material?.publicKey ?? existing?.publicKey ?? String(localized: AppStrings.Keychain.derivedFromThePrivateKey)
}
private var certificateWarning: String? {
guard !certificate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
do {
return try SSHUserCertificate.parse(
certificate, matching: material?.publicKey ?? existing?.publicKey
certificate, matching: publicKey.isEmpty ? nil : publicKey
).validityWarning
} catch {
return error.localizedDescription
@@ -927,25 +919,8 @@ private struct SoftwareKeyEditor: View {
isLoading = false
}
private func derivePublicMaterial() async {
guard !privateKey.isEmpty else {
material = nil
return
}
do {
try await Task.sleep(for: .milliseconds(250))
let privateKey = privateKey
let passphrase = passphrase.isEmpty ? nil : passphrase
let comment = name
material = try await Task.detached(priority: .utility) {
try SSHKeyMaterial.inspect(
privateKey: privateKey, passphrase: passphrase, comment: comment
)
}.value
} catch is CancellationError {
} catch {
material = nil
}
private func refreshPublicMaterial() {
material = try? SSHKeyMaterial.imported(privateKey: privateKey, publicKey: publicKey)
}
private func receiveReferenceText(_ providers: [NSItemProvider]) -> Bool {
@@ -1321,9 +1296,9 @@ struct KeychainEditDetail: View {
existing: key, title: AppStrings.Keychain.editKey, symbol: "pencil",
close: window.closeVaultWork,
loadPrivateKey: { try loadPrivateKey(key) },
saveMetadata: { name, colorTag, certificate in
saveMetadata: { name, colorTag, certificate, publicKey in
try saveSoftwareKeyMetadata(
key, name: name, colorTag: colorTag, certificate: certificate
key, name: name, colorTag: colorTag, certificate: certificate, publicKey: publicKey
)
},
delete: { try delete(key) },
@@ -1370,8 +1345,7 @@ struct KeychainEditDetail: View {
updated.certificate = certificate
updated.fingerprint = material.fingerprint
updated.keySize = material.keySize
let keepsOriginalPrivateKey = privateKey == (try loadPrivateKey(original)).privateKey
updated.hasPassphrase = passphrase != nil || (keepsOriginalPrivateKey && original.hasPassphrase)
updated.hasPassphrase = try SSHKeyMaterial.privateKeyMetadata(privateKey).isEncrypted
try app.credentials.upsert(
updated,
credential: .software(
@@ -1383,11 +1357,18 @@ struct KeychainEditDetail: View {
}
private func saveSoftwareKeyMetadata(
_ original: SSHKeyRef, name: String, colorTag: TagColor, certificate: String
_ original: SSHKeyRef, name: String, colorTag: TagColor, certificate: String, publicKey: String
) throws -> SSHKeyRef {
var updated = original
updated.label = name
updated.colorTag = colorTag
if publicKey != original.publicKey {
let material = publicKey.isEmpty ? nil : try SSHKeyMaterial.publicMaterial(publicKey)
updated.publicKey = material?.publicKey ?? ""
updated.fingerprint = material?.fingerprint ?? ""
updated.algorithm = material?.algorithm ?? updated.algorithm
updated.keySize = material?.keySize ?? updated.keySize
}
updated.certificate = certificate
try app.credentials.upsert(updated)
window.selectVaultItem(.key(updated.id), on: .keychain)
@@ -1526,303 +1507,6 @@ private struct SecureEnclaveKeyEditor: View {
/// Installs one or more public keys on several saved destinations. A checked row is an
/// explicit authorization to change that account's `authorized_keys`; opening
/// the sheet never inherits the Keychain card selection as a host selection.
struct ExportKeyToHostsSheet: View {
let keys: [SSHKeyRef]
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
@Environment(\.dismiss) private var dismiss
@State private var chooser = ManagedChooserState<Host.ID>(mode: .multiple)
@State private var exportModel: KeyExportModel?
@FocusState private var searchFocused: Bool
private var query: String { chooser.query }
private var queryBinding: Binding<String> {
Binding(get: { chooser.query }, set: { chooser.query = $0 })
}
private var hosts: [Host] { app.credentials.searchHosts(query) }
private var eligibleHosts: [Host] {
hosts.filter { app.credentials.keyExportUnavailableReason(for: $0) == nil }
}
private var states: [Host.ID: KeyExportModel.ExportState] { exportModel?.states ?? [:] }
private var isExporting: Bool { exportModel?.isExporting ?? false }
private var isFinished: Bool { exportModel?.isFinished ?? false }
var body: some View {
VaultDetailScaffold(title: AppStrings.Keychain.exportToHosts, symbol: "square.and.arrow.up", close: close) {
VStack(spacing: 0) {
header
Divider().opacity(0.7)
content
}
} footer: {
footer
}
.onAppear { searchFocused = true }
.onChange(of: chooser.query) {
chooser.queryDidChange(
visible: hosts.map(\.id), enabled: Set(eligibleHosts.map(\.id))
)
}
.interactiveDismissDisabled(isExporting)
}
private var header: some View {
VStack(alignment: .leading, spacing: Metrics.Space.m) {
HStack(spacing: Metrics.Space.m) {
CardIcon(symbol: "key.fill", tint: TermindColor.accent)
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
Text(AppStrings.Keychain.exportToHosts)
.font(.headline)
Text(keySummary)
.font(.caption)
.foregroundStyle(.secondary)
}
}
HStack(spacing: Metrics.Space.s) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField(
AppStrings.Vault.searchHosts.swiftUIKey, text: queryBinding.singleLineText, axis: .horizontal
).singleLinePaste()
.lineLimit(1)
.textFieldStyle(.plain)
.focused($searchFocused)
.onKeyPress(.upArrow) { moveCursor(-1) }
.onKeyPress(.downArrow) { moveCursor(1) }
.onKeyPress(.space) { toggleCursor() }
if !query.isEmpty {
Button {
chooser.query = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(Text(AppStrings.Common.clearSearch))
}
}
.padding(Metrics.Space.s)
.background(
.quaternary.opacity(0.5),
in: .rect(cornerRadius: Metrics.Radius.medium)
)
}
.padding(Metrics.Space.xl)
}
@ViewBuilder
private var content: some View {
if app.credentials.hosts.isEmpty {
ContentUnavailableView {
Label(AppStrings.Vault.noHosts.swiftUIKey, systemImage: "server.rack")
} description: {
Text(AppStrings.Keychain.addAndConnectToAHostBeforeExportingAKeyToIt)
}
} else if hosts.isEmpty {
ContentUnavailableView.search(text: query)
} else {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 0) {
ForEach(hosts) { host in
row(for: host)
.frame(height: 52)
.id(host.id)
}
}
}
.onAppear { scrollCursorIntoView(using: proxy) }
.onChange(of: chooser.cursor) { _, _ in scrollCursorIntoView(using: proxy) }
.onChange(of: hosts.map(\.id)) { _, _ in scrollCursorIntoView(using: proxy) }
}
.padding(.horizontal, Metrics.Space.l)
.padding(.vertical, Metrics.Space.s)
}
}
private func scrollCursorIntoView(using proxy: ScrollViewProxy) {
guard let cursor = chooser.cursor else { return }
proxy.scrollTo(cursor)
}
private func row(for host: Host) -> some View {
let unavailable = app.credentials.keyExportUnavailableReason(for: host)
return Button {
chooser.activate(host.id)
} label: {
HStack(spacing: Metrics.Space.m) {
SelectionMark(
mode: .multiple, isSelected: chooser.selected.contains(host.id)
)
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
Text(host.name)
.foregroundStyle(unavailable == nil ? .primary : .secondary)
.lineLimit(1)
Text(app.credentials.loginAddress(for: host))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: Metrics.Space.s)
stateLabel(for: host, unavailable: unavailable)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Metrics.Space.m)
.padding(.vertical, Metrics.Space.s)
.contentShape(.rect)
}
.buttonStyle(.plain)
.disabled(unavailable != nil || isExporting || isFinished)
.accessibilityAddTraits(chooser.selected.contains(host.id) ? .isSelected : [])
}
@ViewBuilder
private func stateLabel(for host: Host, unavailable: String?) -> some View {
switch states[host.id] {
case .running:
ProgressView()
.controlSize(.small)
case .succeeded:
Label(AppStrings.Keychain.exported.swiftUIKey, systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.caption)
case .failed(let message):
Label(AppStrings.Common.failed.swiftUIKey, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
.font(.caption)
.help(Text(message))
case nil:
if let unavailable {
Text(unavailable)
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
}
}
private var footer: some View {
HStack {
if isFinished {
let failures = states.values.reduce(into: 0) { count, state in
if case .failed = state { count += 1 }
}
Text(
failures == 0
? successSummary(hostCount: states.count)
: String(
localized: AppStrings.Keychain.exportedFailed(
count1: states.count - failures, failures: failures))
)
.font(.caption)
.foregroundStyle(
failures == 0 ? AnyShapeStyle(.secondary) : AnyShapeStyle(.red)
)
.monospacedDigit()
} else {
Text(AppStrings.Common.selected(chooser.selected.count))
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
Button(
allEligibleChecked
? AppStrings.Common.deselectAll.swiftUIKey : AppStrings.Common.selectAll.swiftUIKey
) {
toggleAllEligible()
}
.disabled(eligibleHosts.isEmpty || isExporting)
}
Spacer()
Button(
isFinished ? AppStrings.Common.done.swiftUIKey : AppStrings.Common.cancel.swiftUIKey, action: close
)
.keyboardShortcut(.cancelAction)
.disabled(isExporting)
if !isFinished {
Button(AppStrings.Common.export.swiftUIKey, action: export)
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(chooser.selected.isEmpty || isExporting)
}
}
.padding(Metrics.Space.l)
}
private func close() {
if window.selectedDestination == .vault {
window.closeVaultWork()
} else {
dismiss()
}
}
private var allEligibleChecked: Bool { chooser.allChecked(in: eligibleHosts.map(\.id)) }
private var keySummary: String {
if keys.count == 1 {
return keys[0].label
}
return String(localized: AppStrings.Keychain.keysNamed(keys.count))
}
private func successSummary(hostCount: Int) -> String {
if keys.count == 1 {
return hostCount == 1
? String(localized: AppStrings.Keychain.exportedTo1Host)
: String(localized: AppStrings.Keychain.exportedToHosts(hostCount))
}
return hostCount == 1
? String(localized: AppStrings.Keychain.exportedKeysTo1Host(keys.count))
: String(localized: AppStrings.Keychain.exportedKeysToHosts(count: keys.count, hostCount: hostCount))
}
/// Select All follows the visible, filtered list, matching the host chooser:
/// a filter must never cause an invisible machine to be modified.
private func toggleAllEligible() {
chooser.toggleAll(in: eligibleHosts.map(\.id))
}
private func moveCursor(_ delta: Int) -> KeyPress.Result {
let ids = eligibleHosts.map(\.id)
chooser.moveCursor(by: delta, through: ids)
return ids.isEmpty ? .ignored : .handled
}
private func toggleCursor() -> KeyPress.Result {
guard let cursor = chooser.cursor else { return .ignored }
chooser.toggle(cursor)
return .handled
}
private func export() {
guard !isExporting, !isFinished else { return }
let model: KeyExportModel
if let exportModel {
model = exportModel
} else {
let connectionCoordinator = app.connections
model = KeyExportModel(
keys: keys, credentials: app.credentials,
services: KeyExportServices { keys, configuration in
try await connectionCoordinator.installAuthorizedKeys(keys, using: configuration)
})
exportModel = model
}
let selected = Array(chooser.selected)
Task { await model.export(to: selected) }
}
}
// MARK: - Key card
/// The singular actions for one key, shared by its card and editor surfaces.
@@ -1834,17 +1518,17 @@ struct KeyObjectActions: View {
let export: () -> Void
var body: some View {
Button(AppStrings.Keychain.exportToHosts.swiftUIKey, systemImage: MenuSymbol.export, action: export)
.disabled(key.publicKey.isEmpty)
Button(AppStrings.Keychain.exportToHost.swiftUIKey, systemImage: MenuSymbol.export, action: export)
Divider()
Menu(AppStrings.Common.copy.swiftUIKey, systemImage: MenuSymbol.copy) {
Button(AppStrings.Keychain.copyPublicKey.swiftUIKey, systemImage: MenuSymbol.copy) {
Button(AppStrings.Keychain.publicKey.swiftUIKey, systemImage: MenuSymbol.copy) {
copyToPasteboard(key.publicKey)
}
.disabled(key.publicKey.isEmpty)
Button(AppStrings.KnownHosts.copyFingerprint.swiftUIKey, systemImage: MenuSymbol.copy) {
Button(AppStrings.KnownHosts.fingerprint.swiftUIKey, systemImage: MenuSymbol.copy) {
copyToPasteboard(key.fingerprint)
}
.disabled(key.publicKey.isEmpty || key.fingerprint.isEmpty)
}
Button(AppStrings.Common.edit.swiftUIKey, systemImage: MenuSymbol.edit, action: edit)
}
@@ -1883,41 +1567,22 @@ private struct KeyCard: View {
tint: key.colorTag.color ?? TermindColor.accent
)
// The fingerprint is deliberately *not* selectable, and this
// is not a style preference.
//
// A SHA256 fingerprint is wider than the widest card, so it
// has to truncate. `.textSelection(.enabled)` used to be on
// the line below, and it laid out correctly right up until
// someone clicked it: entering selection mode re-lays the
// text out at its full width, and it walked out of the card
// and over its neighbour. That made it look like a rendering
// glitch rather than a layout bug, because no static
// screenshot ever showed it.
//
// Nothing is lost. The whole fingerprint is on the tooltip
// below and one right-click away via Copy Fingerprint, which
// is a better trade than a card that changes size when you
// touch it. The old forwarding manager had the same defect for the
// same reason.
//
// The frame keeps the column to the width it is offered, so
// no future subtitle can push the card wide either.
VStack(alignment: .leading, spacing: 1) {
Text(key.label)
.font(.headline)
.lineLimit(1)
Text(key.fingerprint)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
Text(
key.algorithm == .unknown
? AppStrings.Keychain.unknownKeyType : AppStrings.Keychain.keyType(keyDisplayName)
)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack(spacing: Metrics.Space.xs) {
CardChip(text: keyDisplayName)
if key.hasPassphrase {
CardChip(
text: String(
@@ -1960,9 +1625,6 @@ private struct KeyCard: View {
.contextMenu {
if actionKeys.count == 1, actionIdentities.isEmpty {
KeyObjectActions(key: key, edit: edit, export: export)
} else if actionIdentities.isEmpty {
Button(AppStrings.Keychain.exportToHosts.swiftUIKey, systemImage: MenuSymbol.export, action: export)
.disabled(actionKeys.isEmpty || actionKeys.contains { $0.publicKey.isEmpty })
}
Divider()
DestructiveMenuButton(AppStrings.Common.delete, action: delete)
@@ -216,7 +216,7 @@ struct KnownHostEditorView: View {
}
Section(AppStrings.KnownHosts.hostKey.swiftUIKey) {
Picker(AppStrings.KnownHosts.keyType.swiftUIKey, selection: $draft.algorithm) {
ForEach(SSHKeyAlgorithm.allCases) { Text($0.displayName).tag($0) }
ForEach(SSHKeyAlgorithm.allCases.filter { $0 != .unknown }) { Text($0.displayName).tag($0) }
}
TextField(
AppStrings.KnownHosts.fingerprint.swiftUIKey, text: $draft.fingerprint.singleLineText,
+1 -1
View File
@@ -96,7 +96,7 @@ nonisolated enum VaultWorkRoute: Hashable, Sendable {
case knownHostEditor(KnownHostEntry.ID?)
case sshImport(knownHostsOnly: Bool)
case editKey(SSHKeyRef.ID)
case exportKeys([SSHKeyRef.ID])
case exportKey(SSHKeyRef.ID)
case snippetEditor(snippetID: Snippet.ID?, packageID: SnippetPackage.ID?)
case snippetPackageEditor(packageID: SnippetPackage.ID?, parentID: SnippetPackage.ID?)
case snippetExecutionTargets(SnippetHostChooserRequest)
+2 -2
View File
@@ -72,8 +72,8 @@ struct VaultWorkDetailView: View {
KeychainImportDetail()
case .editKey(let id):
KeychainEditDetail(keyID: id)
case .exportKeys(let ids):
ExportKeyToHostsSheet(keys: ids.compactMap(app.hostLibrary.key))
case .exportKey(let id):
KeyExportDetailView(keyID: id)
case .snippetEditor(let snippetID, let packageID):
SnippetEditorView(
snippet: snippetID.flatMap(app.hostLibrary.snippet), packageID: packageID,
+155
View File
@@ -0,0 +1,155 @@
import Foundation
import TermindApplication
import TermindDomain
import Testing
@testable import TermindRemoteInfrastructure
struct DeferredKeyMaterialTests {
@Test func nestedSSHFieldsAcceptDataSlicesWithNonzeroIndices() throws {
let container = Data([99, 99, 0, 0, 0, 7, 0, 0, 0, 3, 1, 2, 3])
let slice = container.dropFirst(2)
#expect(slice.startIndex != 0)
var offset = 0
let nested = try #require(SSHKeyMaterial.readSSHField(slice, offset: &offset))
#expect(offset == slice.count)
var nestedOffset = 0
#expect(SSHKeyMaterial.readSSHField(nested, offset: &nestedOffset) == Data([1, 2, 3]))
#expect(nestedOffset == nested.count)
}
@Test func invalidSSHFieldBoundsFailWithoutTrappingOrAdvancing() {
for bytes in [Data([0, 0, 0]), Data([255, 255, 255, 255]), Data([0, 0, 0, 2, 1])] {
for initialOffset in [-1, 0, Int.max] {
var offset = initialOffset
#expect(SSHKeyMaterial.readSSHField(bytes, offset: &offset) == nil)
#expect(offset == initialOffset)
}
}
}
@Test(arguments: [SSHKeyMaterial.Generation.ed25519, .rsa(bits: 2048), .ecdsa(bits: 256)])
func importRecognizesEncryptionWithoutUnlockingOrDeriving(_ generation: SSHKeyMaterial.Generation) throws {
let encrypted = try SSHKeyMaterial.generate(generation, comment: "test", passphrase: "secret")
let metadata = try SSHKeyMaterial.privateKeyMetadata(encrypted.privateKey)
#expect(metadata.isEncrypted && metadata.algorithm == .unknown)
let imported = try SSHKeyMaterial.imported(privateKey: encrypted.privateKey, publicKey: "")
#expect(imported.publicKey.isEmpty && imported.fingerprint.isEmpty && imported.algorithm == .unknown)
let supplied = try SSHKeyMaterial.imported(privateKey: encrypted.privateKey, publicKey: encrypted.publicKey)
#expect(supplied.fingerprint == encrypted.fingerprint && supplied.algorithm == encrypted.algorithm)
let plaintext = try SSHKeyMaterial.generate(generation, comment: "", passphrase: nil)
#expect(try !SSHKeyMaterial.privateKeyMetadata(plaintext.privateKey).isEncrypted)
#expect(try SSHKeyMaterial.imported(privateKey: plaintext.privateKey, publicKey: "").publicKey.isEmpty)
}
@Test(arguments: [
"", "not a key", "-----BEGIN ENCRYPTED PRIVATE KEY-----\nAAAA\n-----END ENCRYPTED PRIVATE KEY-----",
])
func invalidPrivateEnvelopesCannotBeSaved(_ input: String) {
#expect(throws: (any Error).self) { try SSHKeyMaterial.imported(privateKey: input, publicKey: "") }
}
@Test func suppliedPublicKeyMustHaveAValidWireBlob() throws {
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
for value in [
"ssh-ed25519 AAAA", material.publicKey + "\n" + material.publicKey,
material.publicKey.replacingOccurrences(of: "ssh-ed25519", with: "ssh-rsa"),
] {
#expect(throws: (any Error).self) { try SSHKeyMaterial.publicMaterial(value) }
}
}
@Test func opensshAndTraditionalPEMEncryptionIsDetectedWithoutAPassphrase() throws {
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
for (format, type) in [("", "ed25519"), ("PEM", "rsa"), ("PEM", "ecdsa")] {
let path = directory.appending(path: UUID().uuidString).path
var args = ["-q", "-t", type, "-N", "fixture-passphrase", "-f", path]
if !format.isEmpty { args += ["-m", format] }
try run("/usr/bin/ssh-keygen", args)
let text = try String(contentsOfFile: path, encoding: .utf8)
#expect(try SSHKeyMaterial.privateKeyMetadata(text).isEncrypted)
#expect(try SSHKeyMaterial.imported(privateKey: text, publicKey: "").publicKey.isEmpty)
}
}
@Test(arguments: ["ed25519", "rsa", "ecdsa"])
func editingUnencryptedOpenSSHKeysReadsNestedMetadataWithoutDeriving(_ type: String) throws {
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let path = directory.appending(path: "key").path
try run("/usr/bin/ssh-keygen", ["-q", "-t", type, "-N", "", "-f", path])
let privateKey = try String(contentsOfFile: path, encoding: .utf8)
let publicKey = try String(contentsOfFile: path + ".pub", encoding: .utf8)
#expect(try !SSHKeyMaterial.privateKeyMetadata(privateKey).isEncrypted)
let material = try SSHKeyMaterial.imported(privateKey: privateKey, publicKey: publicKey)
#expect(material.algorithm != .unknown && !material.fingerprint.isEmpty)
#expect(try SSHKeyMaterial.imported(privateKey: privateKey, publicKey: "").publicKey.isEmpty)
}
@Test func defaultExportScriptQuotesArgumentsAndDoesNotDuplicateTheKey() throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let directory = root.appending(path: "keys '$HOME; $(touch injected)")
let name = "authorized_keys';touch injected;#"
let material = try SSHKeyMaterial.generate(.ed25519, comment: "$(touch injected)", passphrase: nil)
let options = KeyExportOptions(
directory: directory.path, filename: name,
script: KeyExportOptions.defaultScript(keyName: "Work\n$(touch injected)"))
let command = try SSHKeyExportSession.command(publicKey: material.publicKey, options: options)
try run("/bin/sh", ["-c", command], directory: root)
try run("/bin/sh", ["-c", command], directory: root)
let file = directory.appending(path: name)
let contents = try String(contentsOf: file, encoding: .utf8)
#expect(contents.components(separatedBy: material.publicKey).count == 2)
#expect(contents.hasPrefix("# Work $(touch injected) Key by Termind\n"))
#expect(!FileManager.default.fileExists(atPath: root.appending(path: "injected").path))
#expect(try FileManager.default.attributesOfItem(atPath: directory.path)[.posixPermissions] as? Int == 0o700)
#expect(try FileManager.default.attributesOfItem(atPath: file.path)[.posixPermissions] as? Int == 0o600)
}
@Test func customScriptReceivesThreeLiteralArguments() throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let material = try SSHKeyMaterial.generate(.ed25519, comment: "comment", passphrase: nil)
let options = KeyExportOptions(
directory: root.path, filename: "custom",
script: "printf '%s\\n%s\\n' \"$2\" \"$3\" > \"$1/$2\"")
try run("/bin/sh", ["-c", SSHKeyExportSession.command(publicKey: material.publicKey, options: options)])
#expect(
try String(contentsOf: root.appending(path: "custom"), encoding: .utf8) == "custom\n\(material.publicKey)\n"
)
}
@Test func defaultScriptPreservesAnExistingKeyWithoutATrailingNewline() throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let existing = try SSHKeyMaterial.generate(.ed25519, comment: "existing", passphrase: nil)
let next = try SSHKeyMaterial.generate(.ed25519, comment: "next", passphrase: nil)
let file = root.appending(path: "authorized_keys")
try existing.publicKey.write(to: file, atomically: true, encoding: .utf8)
let options = KeyExportOptions(directory: root.path, script: KeyExportOptions.defaultScript(keyName: "Next"))
try run("/bin/sh", ["-c", SSHKeyExportSession.command(publicKey: next.publicKey, options: options)])
let lines = try String(contentsOf: file, encoding: .utf8).split(separator: "\n").map(String.init)
#expect(lines == [existing.publicKey, "# Next Key by Termind", next.publicKey])
}
private func run(_ executable: String, _ arguments: [String], directory: URL? = nil) throws {
let process = Process()
process.executableURL = URL(filePath: executable)
process.arguments = arguments
process.currentDirectoryURL = directory
let errors = Pipe()
process.standardError = errors
try process.run()
process.waitUntilExit()
let output = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
#expect(process.terminationStatus == 0, "\(output)")
guard process.terminationStatus == 0 else { throw CocoaError(.executableRuntimeMismatch) }
}
}
+60
View File
@@ -0,0 +1,60 @@
import AppKit
import SwiftUI
import TermindApplication
import TermindDomain
import TermindRemoteInfrastructure
import Testing
@testable import Termind
@Suite(.requiresVisibleWindow, .serialized) @MainActor
struct KeyExportRenderingTests {
@Test(arguments: ["en", "zh-Hans"])
func encryptedExportShowsItsPassphraseInsideTheVaultForm(_ language: String) async throws {
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: "secret")
let key = SSHKeyRef(label: "Deployment Key", algorithm: .unknown, credentialRef: "key", hasPassphrase: true)
let host = TermindDomain.Host(name: "Production", host: "server.example.com")
let library = HostLibraryModel(hosts: [host], keys: [key])
try library.upsert(key, credential: .software(SSHPrivateKeySecret(privateKey: material.privateKey)))
let app = AppModel(hosts: library)
let navigation = WindowModel()
navigation.openVaultWork(.exportKey(key.id), on: .keychain)
let controller = NSHostingController(
rootView:
VaultWorkspaceView().environment(app).environment(navigation)
.environment(\.locale, Locale(identifier: language))
.frame(width: 1040, height: 760))
let window = NSWindow(
contentRect: CGRect(x: 0, y: 0, width: 1040, height: 760),
styleMask: [.titled, .resizable], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
controller.sizingOptions = []
window.contentViewController = controller
window.makeKeyAndOrderFront(nil)
defer {
window.close()
app.shutdownForTermination()
}
try await Task.sleep(for: .milliseconds(700))
controller.view.layoutSubtreeIfNeeded()
#expect(window.attachedSheet == nil)
#expect(descendants(controller.view).contains { $0 is NSSecureTextField })
#expect(app.externalAuthentication.pending.isEmpty)
guard let image = controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds) else {
Issue.record("Unable to capture export form")
return
}
controller.view.cacheDisplay(in: controller.view.bounds, to: image)
let directory = FileManager.default.temporaryDirectory.appending(path: "key-export-screenshots")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let output = directory.appending(path: "macos-\(language)-form.png")
let data = try #require(image.representation(using: .png, properties: [:]))
Attachment.record(data, named: output.lastPathComponent)
try data.write(to: output)
print("Key export snapshot: \(output.path)")
}
private func descendants(_ view: NSView) -> [NSView] {
[view] + view.subviews.flatMap(descendants)
}
}