feat(keychain): add single-host export and deferred public keys
This commit is contained in:
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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user