refactor(ssh): share key workflows and wire encoding
This commit is contained in:
1 parent
816a6ff121
commit
565ef17d87
27 files changed
+575
-413
No files matched your search
@@ -0,0 +1,214 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import Testing
|
||||
|
||||
@Suite @MainActor
|
||||
struct KeyExportTests {
|
||||
@Test func openingAndEmptySelectionNeverInstallAnything() async {
|
||||
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)
|
||||
}
|
||||
|
||||
@Test func missingPublicKeyCannotBeExported() async {
|
||||
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)
|
||||
}
|
||||
|
||||
@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 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 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 exportsEverySelectedKeyOncePerRemainingHost() async {
|
||||
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])
|
||||
}
|
||||
|
||||
@Test func removedDestinationsAndEmptyKeyBatchesNeverStartAnExport() async {
|
||||
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)
|
||||
}
|
||||
|
||||
@Test func revokedTrustFailsWithoutConnectingAndDoesNotBlockOtherHosts() async {
|
||||
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)
|
||||
}
|
||||
|
||||
@Test func runningExportRejectsDuplicateSubmissions() async {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
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)
|
||||
}
|
||||
|
||||
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" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import TermindRemoteInfrastructure
|
||||
|
||||
struct SSHWireEncodingTests {
|
||||
@Test func nonnegativeMpintsMatchRFC4251WireValues() {
|
||||
// RFC 4251 section 5: zero is an empty string; positive high-bit values need a sign octet.
|
||||
let vectors: [(Data, Data)] = [
|
||||
(Data(), Data([0, 0, 0, 0])),
|
||||
(Data([0, 0]), Data([0, 0, 0, 0])),
|
||||
(Data([0, 0, 0x12, 0x34]), Data([0, 0, 0, 2, 0x12, 0x34])),
|
||||
(Data([0x7f]), Data([0, 0, 0, 1, 0x7f])),
|
||||
(Data([0x80]), Data([0, 0, 0, 2, 0, 0x80])),
|
||||
(Data([0, 0x9a, 0x37, 0x8f, 0x9b]), Data([0, 0, 0, 5, 0, 0x9a, 0x37, 0x8f, 0x9b])),
|
||||
]
|
||||
for (integer, wire) in vectors { #expect(SSHWireEncoding.mpint(integer) == wire) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import Testing
|
||||
|
||||
@Suite @MainActor
|
||||
struct SecurityKeyEditorTests {
|
||||
private let credential = SecurityKeyCredential(
|
||||
credentialID: Data([1, 2, 3]), publicKeyX963: Data([4, 5, 6]), userVerification: .discouraged)
|
||||
|
||||
@Test func savesEditedMetadataWithoutReplacingKeyIdentityOrPublicMaterial() async {
|
||||
let existing = SSHKeyRef(
|
||||
label: "Original", algorithm: .securityKey, publicKey: "public-key", fingerprint: "fingerprint",
|
||||
storage: .thisDevice, securityKey: credential)
|
||||
var saved: SSHKeyRef?
|
||||
var validated: SecurityKeyCredential?
|
||||
let model = SecurityKeyEditorModel(
|
||||
existing: existing, exportReference: { _ in "reference" }, validate: { validated = $0 },
|
||||
persist: { saved = $0 })
|
||||
model.load()
|
||||
#expect(model.label == "Original" && model.reference == "reference")
|
||||
#expect(model.verification == .discouraged && model.canSave)
|
||||
model.label = " Renamed "
|
||||
model.colorTag = .purple
|
||||
model.verification = .required
|
||||
#expect(await model.save())
|
||||
var expected = existing
|
||||
expected.label = "Renamed"
|
||||
expected.colorTag = .purple
|
||||
expected.securityKey?.userVerification = .required
|
||||
#expect(saved == expected)
|
||||
#expect(validated == expected.securityKey)
|
||||
#expect(model.error == nil && !model.isSaving)
|
||||
}
|
||||
|
||||
@Test func validationAndPersistenceFailuresKeepTheEditorOpen() async {
|
||||
var persisted = false
|
||||
let existing = SSHKeyRef(label: "Key", securityKey: credential)
|
||||
let rejected = SecurityKeyEditorModel(
|
||||
existing: existing, exportReference: { _ in "reference" }, validate: { _ in throw EditFailure() },
|
||||
persist: { _ in persisted = true })
|
||||
rejected.load()
|
||||
#expect(!(await rejected.save()))
|
||||
#expect(!persisted && rejected.error == "Fixture edit failure")
|
||||
#expect(rejected.canSave)
|
||||
let failed = SecurityKeyEditorModel(
|
||||
existing: existing, exportReference: { _ in "reference" }, validate: { _ in },
|
||||
persist: { _ in throw EditFailure() })
|
||||
failed.load()
|
||||
#expect(!(await failed.save()))
|
||||
#expect(failed.error == "Fixture edit failure" && failed.canSave)
|
||||
}
|
||||
|
||||
@Test func cancellationBeforeSaveDoesNotPersistOrReportAnError() async {
|
||||
var persisted = false
|
||||
let model = SecurityKeyEditorModel(
|
||||
existing: SSHKeyRef(label: "Key", securityKey: credential), exportReference: { _ in "reference" },
|
||||
validate: { _ in }, persist: { _ in persisted = true })
|
||||
model.load()
|
||||
let task = Task { await model.save() }
|
||||
task.cancel()
|
||||
#expect(!(await task.value))
|
||||
#expect(!persisted && model.error == nil && !model.isSaving)
|
||||
}
|
||||
|
||||
@Test func missingCredentialCannotBeSaved() async {
|
||||
var persisted = false
|
||||
let model = SecurityKeyEditorModel(
|
||||
existing: SSHKeyRef(label: "Missing"), exportReference: { _ in "reference" }, validate: { _ in },
|
||||
persist: { _ in persisted = true })
|
||||
model.load()
|
||||
#expect(!model.canSave && model.error != nil)
|
||||
#expect(!(await model.save()))
|
||||
#expect(!persisted)
|
||||
}
|
||||
|
||||
private struct EditFailure: LocalizedError {
|
||||
var errorDescription: String? { "Fixture edit failure" }
|
||||
}
|
||||
}
|
||||
@@ -380,10 +380,7 @@ extension RemoteSessionHostStore {
|
||||
if let stored = try connectionKeySecretCredential(for: key) {
|
||||
credentials.append(
|
||||
stored.sshCredential(
|
||||
publicKey: key.publicKey,
|
||||
certificate: key.certificate,
|
||||
requiresPassphrase: key.requiresPassphraseEntry,
|
||||
requiresUserPresence: key.requiresUserPresence, keyName: key.label
|
||||
for: key
|
||||
)
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -163,10 +163,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
|
||||
}
|
||||
result.append(
|
||||
stored.sshCredential(
|
||||
publicKey: key.publicKey, certificate: key.certificate,
|
||||
passphraseOverride: node.submission?.passphraseForConnection(),
|
||||
requiresPassphrase: key.requiresPassphraseEntry,
|
||||
requiresUserPresence: key.requiresUserPresence, keyName: key.label
|
||||
for: key, passphraseOverride: node.submission?.passphraseForConnection()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,10 +754,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
if let stored = try keySecretCredential(for: key) {
|
||||
credentials.append(
|
||||
stored.sshCredential(
|
||||
publicKey: key.publicKey, certificate: key.certificate,
|
||||
passphraseOverride: submission.passphraseForConnection(),
|
||||
requiresPassphrase: key.requiresPassphraseEntry,
|
||||
requiresUserPresence: key.requiresUserPresence, keyName: key.label
|
||||
for: key, passphraseOverride: submission.passphraseForConnection()
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -827,9 +824,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
if let credential = try keySecretCredential(for: key) {
|
||||
credentials.append(
|
||||
credential.sshCredential(
|
||||
publicKey: key.publicKey, certificate: key.certificate,
|
||||
requiresPassphrase: key.requiresPassphraseEntry,
|
||||
requiresUserPresence: key.requiresUserPresence, keyName: key.label
|
||||
for: key
|
||||
)
|
||||
)
|
||||
} else {
|
||||
|
||||
+25
-29
@@ -1,55 +1,51 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
struct MobileKeyExportServices: Sendable {
|
||||
let install: @Sendable ([SSHAuthorizedKeyInstall], SSHConnectionConfiguration) async throws -> Void
|
||||
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
|
||||
final class MobileKeyExportModel {
|
||||
enum ExportState: Equatable {
|
||||
public final class KeyExportModel {
|
||||
public enum ExportState: Equatable {
|
||||
case running
|
||||
case succeeded
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
let key: SSHKeyRef
|
||||
private(set) var selected: Set<Host.ID> = []
|
||||
private(set) var states: [Host.ID: ExportState] = [:]
|
||||
private(set) var isExporting = false
|
||||
private(set) var isFinished = false
|
||||
private(set) var targets: [Host] = []
|
||||
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
|
||||
private let services: MobileKeyExportServices
|
||||
private let services: KeyExportServices
|
||||
|
||||
init(key: SSHKeyRef, credentials: CredentialLibraryModel, services: MobileKeyExportServices) {
|
||||
self.key = key
|
||||
public init(keys: [SSHKeyRef], credentials: CredentialLibraryModel, services: KeyExportServices) {
|
||||
self.keys = keys
|
||||
self.credentials = credentials
|
||||
self.services = services
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func updateSelection(_ ids: Set<Host.ID>) {
|
||||
public func updateSelection(_ ids: Set<Host.ID>) {
|
||||
guard !isExporting, !isFinished else { return }
|
||||
selected = ids
|
||||
}
|
||||
|
||||
func export(to hostIDs: [Host.ID]) async {
|
||||
guard !key.publicKey.isEmpty, !hostIDs.isEmpty, !isExporting, !isFinished else { return }
|
||||
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.
|
||||
@@ -70,14 +66,14 @@ final class MobileKeyExportModel {
|
||||
states[host.id] = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
let keys = [SSHAuthorizedKeyInstall(publicKey: key.publicKey, name: key.label)]
|
||||
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(keys, configuration)
|
||||
try await install(exportKeys, configuration)
|
||||
return (id, nil)
|
||||
} catch {
|
||||
return (id, error.localizedDescription)
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import TermindDomain
|
||||
|
||||
/// Editable metadata for an existing hardware key; validation and persistence remain injected.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class SecurityKeyEditorModel {
|
||||
public var label = ""
|
||||
public var colorTag = TagColor.none
|
||||
public var verification: SecurityKeyCredential.UserVerification = .preferred
|
||||
public private(set) var reference = ""
|
||||
public private(set) var error: String?
|
||||
public private(set) var isSaving = false
|
||||
public var canSave: Bool { originalCredential != nil && !isSaving }
|
||||
public let existing: SSHKeyRef
|
||||
|
||||
private var originalCredential: SecurityKeyCredential?
|
||||
private let exportReference: (SecurityKeyCredential) throws -> String
|
||||
private let validate: (SecurityKeyCredential) throws -> Void
|
||||
private let persist: (SSHKeyRef) throws -> Void
|
||||
|
||||
public init(
|
||||
existing: SSHKeyRef,
|
||||
exportReference: @escaping (SecurityKeyCredential) throws -> String,
|
||||
validate: @escaping (SecurityKeyCredential) throws -> Void,
|
||||
persist: @escaping (SSHKeyRef) throws -> Void
|
||||
) {
|
||||
self.existing = existing
|
||||
self.exportReference = exportReference
|
||||
self.validate = validate
|
||||
self.persist = persist
|
||||
}
|
||||
|
||||
public func load() {
|
||||
label = existing.label
|
||||
colorTag = existing.colorTag
|
||||
do {
|
||||
guard let credential = existing.securityKey else { throw TermindSSHError.missingCredential }
|
||||
originalCredential = credential
|
||||
verification = credential.userVerification
|
||||
reference = try exportReference(credential)
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
public func save() async -> Bool {
|
||||
guard !isSaving else { return false }
|
||||
isSaving = true
|
||||
error = nil
|
||||
defer { isSaving = false }
|
||||
do {
|
||||
guard var credential = originalCredential else { throw TermindSSHError.missingCredential }
|
||||
credential.userVerification = verification
|
||||
try validate(credential)
|
||||
try Task.checkCancellation()
|
||||
var key = existing
|
||||
key.label = label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
key.colorTag = colorTag
|
||||
key.securityKey = credential
|
||||
try persist(key)
|
||||
return true
|
||||
} catch is CancellationError {
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -356,6 +356,13 @@ public nonisolated enum SSHResolvedKeyCredential: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public func sshCredential(for key: SSHKeyRef, passphraseOverride: SSHSecret? = nil) -> SSHCredentials {
|
||||
sshCredential(
|
||||
publicKey: key.publicKey, certificate: key.certificate, passphraseOverride: passphraseOverride,
|
||||
requiresPassphrase: key.requiresPassphraseEntry,
|
||||
requiresUserPresence: key.requiresUserPresence, keyName: key.label)
|
||||
}
|
||||
|
||||
public func sshCredential(
|
||||
publicKey: String, certificate: String = "", passphraseOverride: SSHSecret? = nil,
|
||||
requiresPassphrase: Bool = false, requiresUserPresence: Bool = false, keyName: String = "Security Key"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
public struct SecurityKeyEditorForm: View {
|
||||
@State private var model: SecurityKeyEditorModel
|
||||
@State private var task: Task<Void, Never>?
|
||||
private let close: () -> Void
|
||||
|
||||
public init(
|
||||
existing: SSHKeyRef,
|
||||
exportReference: @escaping (SecurityKeyCredential) throws -> String,
|
||||
validate: @escaping (SecurityKeyCredential) throws -> Void,
|
||||
persist: @escaping (SSHKeyRef) throws -> Void,
|
||||
close: @escaping () -> Void
|
||||
) {
|
||||
_model = State(
|
||||
initialValue: SecurityKeyEditorModel(
|
||||
existing: existing, exportReference: exportReference, validate: validate, persist: persist))
|
||||
self.close = close
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Form {
|
||||
Section {
|
||||
Label(AppStrings.SecurityKey.title.swiftUIKey, systemImage: "key.radiowaves.forward.fill").font(
|
||||
.headline)
|
||||
VaultNameField(text: $model.label, prompt: Text(AppStrings.SecurityKey.title))
|
||||
VaultColorField(selection: $model.colorTag)
|
||||
}
|
||||
Section {
|
||||
LabeledContent(AppStrings.Keychain.storeIn.swiftUIKey) {
|
||||
Label(AppStrings.SecurityKey.title.swiftUIKey, systemImage: "key.radiowaves.forward.fill")
|
||||
}
|
||||
WebAuthnVerificationPicker(selection: $model.verification)
|
||||
if !model.reference.isEmpty {
|
||||
Text(AppStrings.SecurityKey.reference).font(.headline)
|
||||
Text(model.reference).font(.caption.monospaced()).textSelection(.enabled)
|
||||
}
|
||||
Text(model.existing.publicKey).font(.caption.monospaced()).textSelection(.enabled)
|
||||
}
|
||||
if let error = model.error { Text(error).foregroundStyle(.red) }
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
HStack {
|
||||
Button(AppStrings.Common.cancel.swiftUIKey) {
|
||||
task?.cancel()
|
||||
close()
|
||||
}
|
||||
Spacer()
|
||||
if model.isSaving { ProgressView().controlSize(.small) }
|
||||
Button(AppStrings.Common.save.swiftUIKey) {
|
||||
task = Task { if await model.save() { close() } }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!model.canSave)
|
||||
}.padding()
|
||||
}
|
||||
.task { model.load() }
|
||||
.onDisappear { task?.cancel() }
|
||||
}
|
||||
}
|
||||
@@ -59,11 +59,8 @@ nonisolated struct SSHAgentProtocol {
|
||||
}
|
||||
|
||||
enum Failure: Error { case invalid, unavailable }
|
||||
static func uint32(_ value: UInt32) -> Data {
|
||||
var value = value.bigEndian
|
||||
return withUnsafeBytes(of: &value) { Data($0) }
|
||||
}
|
||||
static func field(_ data: Data) -> Data { uint32(UInt32(data.count)) + data }
|
||||
static func uint32(_ value: UInt32) -> Data { SSHWireEncoding.uint32(value) }
|
||||
static func field(_ data: Data) -> Data { SSHWireEncoding.field(data) }
|
||||
static func frame(_ data: Data) -> Data { field(data) }
|
||||
|
||||
struct Reader {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import CSSH2
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import OpenSSL
|
||||
import TermindDomain
|
||||
@@ -115,7 +114,7 @@ public nonisolated struct SSHKeyMaterial: Sendable {
|
||||
+ (trimmedComment.isEmpty ? "" : " \(trimmedComment)")
|
||||
return SSHKeyMaterial(
|
||||
privateKey: privateKey, publicKey: publicKey, algorithm: algorithm,
|
||||
fingerprint: fingerprint(of: publicBlob),
|
||||
fingerprint: SSHWireEncoding.fingerprint(of: publicBlob),
|
||||
keySize: keySize ?? inferredKeySize(for: algorithm, publicBlob: publicBlob)
|
||||
)
|
||||
}
|
||||
@@ -269,12 +268,6 @@ public nonisolated struct SSHKeyMaterial: Sendable {
|
||||
value?.isEmpty == false ? value : nil
|
||||
}
|
||||
|
||||
private static func fingerprint(of blob: Data) -> String {
|
||||
let digest = Data(SHA256.hash(data: blob)).base64EncodedString()
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
return "SHA256:\(digest)"
|
||||
}
|
||||
|
||||
private static func connectionError(_ session: OpaquePointer, code: Int32) -> String {
|
||||
var message: UnsafeMutablePointer<CChar>?
|
||||
var length = Int32(0)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// RFC 4251 fields shared by public keys, signatures, and agent replies.
|
||||
nonisolated enum SSHWireEncoding {
|
||||
static func uint32(_ value: UInt32) -> Data {
|
||||
var value = value.bigEndian
|
||||
return withUnsafeBytes(of: &value) { Data($0) }
|
||||
}
|
||||
|
||||
static func field(_ data: Data) -> Data { uint32(UInt32(data.count)) + data }
|
||||
|
||||
/// Encodes a nonnegative integer, including its SSH field length.
|
||||
static func mpint<S: DataProtocol>(_ bytes: S) -> Data {
|
||||
var value = Data(Data(bytes).drop(while: { $0 == 0 }))
|
||||
if value.first.map({ $0 & 0x80 != 0 }) == true { value.insert(0, at: value.startIndex) }
|
||||
return field(value)
|
||||
}
|
||||
|
||||
static func fingerprint(of blob: Data) -> String {
|
||||
let digest = Data(SHA256.hash(data: blob)).base64EncodedString()
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
return "SHA256:\(digest)"
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ public nonisolated enum SecureEnclaveKey {
|
||||
dataRepresentation: privateKey.dataRepresentation
|
||||
),
|
||||
publicKey: publicKey,
|
||||
fingerprint: fingerprint(of: publicBlob)
|
||||
fingerprint: SSHWireEncoding.fingerprint(of: publicBlob)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,8 +80,8 @@ public nonisolated enum SecureEnclaveKey {
|
||||
let rawSignature = try privateKey.signature(for: data).rawRepresentation
|
||||
try Task.checkCancellation()
|
||||
guard rawSignature.count == 64 else { throw SecureEnclaveKeyError.invalidSignature }
|
||||
return sshField(mpint(rawSignature.prefix(32)))
|
||||
+ sshField(mpint(rawSignature.suffix(32)))
|
||||
return SSHWireEncoding.mpint(rawSignature.prefix(32))
|
||||
+ SSHWireEncoding.mpint(rawSignature.suffix(32))
|
||||
} catch {
|
||||
throw signingError(error)
|
||||
}
|
||||
@@ -113,30 +113,9 @@ public nonisolated enum SecureEnclaveKey {
|
||||
}
|
||||
|
||||
private static func sshPublicBlob(for x963Representation: Data) -> Data {
|
||||
sshField(Data("ecdsa-sha2-nistp256".utf8))
|
||||
+ sshField(Data("nistp256".utf8))
|
||||
+ sshField(x963Representation)
|
||||
SSHWireEncoding.field(Data("ecdsa-sha2-nistp256".utf8))
|
||||
+ SSHWireEncoding.field(Data("nistp256".utf8))
|
||||
+ SSHWireEncoding.field(x963Representation)
|
||||
}
|
||||
|
||||
private static func fingerprint(of blob: Data) -> String {
|
||||
let digest = Data(SHA256.hash(data: blob)).base64EncodedString()
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
return "SHA256:\(digest)"
|
||||
}
|
||||
|
||||
private static func mpint<S: DataProtocol>(_ bytes: S) -> Data {
|
||||
var value = Data(bytes).drop(while: { $0 == 0 })
|
||||
if value.isEmpty { return Data() }
|
||||
if value.first.map({ $0 & 0x80 != 0 }) == true {
|
||||
value.insert(0, at: value.startIndex)
|
||||
}
|
||||
return Data(value)
|
||||
}
|
||||
|
||||
private static func sshField(_ value: Data) -> Data {
|
||||
var length = UInt32(value.count).bigEndian
|
||||
var result = withUnsafeBytes(of: &length) { Data($0) }
|
||||
result.append(value)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -62,18 +62,18 @@ public nonisolated enum WebAuthnSSH {
|
||||
|
||||
public static func publicBlob(_ credential: SecurityKeyCredential) throws -> Data {
|
||||
try validate(credential)
|
||||
return field(Data(algorithm.utf8)) + field(Data("nistp256".utf8))
|
||||
+ field(credential.publicKeyX963) + field(Data(SecurityKeyCredential.relyingParty.utf8))
|
||||
return SSHWireEncoding.field(Data(algorithm.utf8)) + SSHWireEncoding.field(Data("nistp256".utf8))
|
||||
+ SSHWireEncoding.field(credential.publicKeyX963)
|
||||
+ SSHWireEncoding.field(Data(SecurityKeyCredential.relyingParty.utf8))
|
||||
}
|
||||
|
||||
public static func keyReference(
|
||||
_ credential: SecurityKeyCredential, label: String, colorTag: TagColor = .none
|
||||
) throws -> SSHKeyRef {
|
||||
let blob = try publicBlob(credential)
|
||||
let fingerprint = Data(SHA256.hash(data: blob)).base64EncodedString().replacingOccurrences(of: "=", with: "")
|
||||
return SSHKeyRef(
|
||||
label: label, algorithm: .securityKey,
|
||||
publicKey: "\(algorithm) \(blob.base64EncodedString())", fingerprint: "SHA256:\(fingerprint)",
|
||||
publicKey: "\(algorithm) \(blob.base64EncodedString())", fingerprint: SSHWireEncoding.fingerprint(of: blob),
|
||||
storage: .thisDevice, securityKey: credential, keySize: 256,
|
||||
requiresUserPresence: credential.requiresUserPresence, createdAt: .now, colorTag: colorTag)
|
||||
}
|
||||
@@ -137,9 +137,10 @@ public nonisolated enum WebAuthnSSH {
|
||||
throw Failure.invalidResponse
|
||||
}
|
||||
let raw = signature.rawRepresentation
|
||||
let pair = mpint(Data(raw.prefix(32))) + mpint(Data(raw.suffix(32)))
|
||||
return field(Data(signatureAlgorithm.utf8)) + field(pair) + Data(auth[32..<37])
|
||||
+ field(Data(origin.utf8)) + field(assertion.clientDataJSON) + field(extensions)
|
||||
let pair = SSHWireEncoding.mpint(Data(raw.prefix(32))) + SSHWireEncoding.mpint(Data(raw.suffix(32)))
|
||||
return SSHWireEncoding.field(Data(signatureAlgorithm.utf8)) + SSHWireEncoding.field(pair) + Data(auth[32..<37])
|
||||
+ SSHWireEncoding.field(Data(origin.utf8)) + SSHWireEncoding.field(assertion.clientDataJSON)
|
||||
+ SSHWireEncoding.field(extensions)
|
||||
}
|
||||
|
||||
private static func validateAuthenticator(_ data: Data, requiresVerification: Bool, requiresPresence: Bool = true)
|
||||
@@ -169,18 +170,6 @@ public nonisolated enum WebAuthnSSH {
|
||||
.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
private static func field(_ data: Data) -> Data {
|
||||
let count = UInt32(data.count)
|
||||
return Data([
|
||||
UInt8(count >> 24), UInt8(truncatingIfNeeded: count >> 16),
|
||||
UInt8(truncatingIfNeeded: count >> 8), UInt8(truncatingIfNeeded: count),
|
||||
]) + data
|
||||
}
|
||||
|
||||
private static func mpint(_ data: Data) -> Data {
|
||||
let value = Data(data.drop(while: { $0 == 0 }))
|
||||
return field((value.first.map { $0 & 0x80 != 0 } == true ? Data([0]) : Data()) + value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded definite-length CBOR parser for authenticator data, with duplicate-map-key rejection.
|
||||
|
||||
@@ -36,7 +36,7 @@ final class MobileAppModel {
|
||||
let hosts: any HostRepository
|
||||
let library: HostLibraryModel
|
||||
let credentials: CredentialLibraryModel
|
||||
let keyExportServices: MobileKeyExportServices
|
||||
let keyExportServices: KeyExportServices
|
||||
let snippets: SnippetLibraryModel
|
||||
let knownHosts: KnownHostsLibraryModel
|
||||
let forwardingLibrary: ForwardingLibraryModel
|
||||
@@ -188,7 +188,7 @@ final class MobileAppModel {
|
||||
let background = dependencies.background
|
||||
let workspaceConnectionServices = dependencies.workspaceConnections
|
||||
let connectionCoordinator = dependencies.connectionCoordinator
|
||||
keyExportServices = MobileKeyExportServices { keys, configuration in
|
||||
keyExportServices = KeyExportServices { keys, configuration in
|
||||
try await connectionCoordinator.installAuthorizedKeys(keys, using: configuration)
|
||||
}
|
||||
activateWorkspaceSession = dependencies.activateWorkspaceSession
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -126,7 +126,7 @@ struct MobileCredentialsView: View {
|
||||
.sheet(item: $exportingKey) { key in
|
||||
MobileKeyExportSheet(
|
||||
library: app.library, credentials: app.credentials,
|
||||
model: MobileKeyExportModel(key: key, credentials: app.credentials, services: app.keyExportServices))
|
||||
model: KeyExportModel(keys: [key], credentials: app.credentials, services: app.keyExportServices))
|
||||
}
|
||||
.confirmationDialog(
|
||||
AppStrings.Keychain.deleteIdentityConfirmation,
|
||||
|
||||
@@ -4,7 +4,7 @@ import TermindApplication
|
||||
struct MobileKeyExportSheet: View {
|
||||
let library: HostLibraryModel
|
||||
let credentials: CredentialLibraryModel
|
||||
@State var model: MobileKeyExportModel
|
||||
@State var model: KeyExportModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -6,7 +6,7 @@ import TermindPresentation
|
||||
extension MobileMultiHostPicker where Header == MobileKeyExportHeader, RowDetail == MobileKeyExportHostDetail {
|
||||
init(
|
||||
library: HostLibraryModel, credentials: CredentialLibraryModel,
|
||||
exporting model: MobileKeyExportModel, close: @escaping () -> Void
|
||||
exporting model: KeyExportModel, close: @escaping () -> Void
|
||||
) {
|
||||
self.init(
|
||||
library: library, useCase: .keyExport, selected: [],
|
||||
@@ -38,11 +38,11 @@ extension MobileMultiHostPicker where Header == MobileKeyExportHeader, RowDetail
|
||||
}
|
||||
|
||||
struct MobileKeyExportHeader: View {
|
||||
let model: MobileKeyExportModel
|
||||
let model: KeyExportModel
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Label(model.key.label, systemImage: "key")
|
||||
if let key = model.keys.first { Label(key.label, systemImage: "key") }
|
||||
if model.isFinished {
|
||||
Text(model.summary).accessibilityIdentifier("key-export.summary")
|
||||
} else {
|
||||
@@ -53,7 +53,7 @@ struct MobileKeyExportHeader: View {
|
||||
}
|
||||
|
||||
struct MobileKeyExportHostDetail: View {
|
||||
let state: MobileKeyExportModel.ExportState?
|
||||
let state: KeyExportModel.ExportState?
|
||||
let unavailable: String?
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -9,89 +9,14 @@ struct SecurityKeyEditor: View {
|
||||
let existing: SSHKeyRef
|
||||
var close: (() -> Void)? = nil
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var label = ""
|
||||
@State private var colorTag = TagColor.none
|
||||
@State private var verification: SecurityKeyCredential.UserVerification = .preferred
|
||||
@State private var reference = ""
|
||||
@State private var originalCredential: SecurityKeyCredential?
|
||||
@State private var error: String?
|
||||
@State private var running = false
|
||||
@State private var task: Task<Void, Never>?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Form {
|
||||
Section {
|
||||
Label(AppStrings.SecurityKey.title.swiftUIKey, systemImage: "key.radiowaves.forward.fill").font(
|
||||
.headline)
|
||||
VaultNameField(text: $label, prompt: Text(AppStrings.SecurityKey.title))
|
||||
VaultColorField(selection: $colorTag)
|
||||
}
|
||||
Section {
|
||||
LabeledContent(AppStrings.Keychain.storeIn.swiftUIKey) {
|
||||
Label(AppStrings.SecurityKey.title.swiftUIKey, systemImage: "key.radiowaves.forward.fill")
|
||||
}
|
||||
WebAuthnVerificationPicker(selection: $verification)
|
||||
if !reference.isEmpty {
|
||||
Text(AppStrings.SecurityKey.reference).font(.headline)
|
||||
Text(reference).font(.caption.monospaced()).textSelection(.enabled)
|
||||
}
|
||||
Text(existing.publicKey).font(.caption.monospaced()).textSelection(.enabled)
|
||||
}
|
||||
if let error { Text(error).foregroundStyle(.red) }
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
HStack {
|
||||
Button(AppStrings.Common.cancel.swiftUIKey) {
|
||||
task?.cancel()
|
||||
finish()
|
||||
}
|
||||
Spacer()
|
||||
if running { ProgressView().controlSize(.small) }
|
||||
Button(AppStrings.Common.save.swiftUIKey) {
|
||||
task = Task { await save() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(running || originalCredential == nil)
|
||||
}.padding()
|
||||
}
|
||||
.task {
|
||||
label = existing.label
|
||||
colorTag = existing.colorTag
|
||||
do {
|
||||
guard let credential = existing.securityKey else {
|
||||
throw TermindSSHError.missingCredential
|
||||
}
|
||||
originalCredential = credential
|
||||
verification = credential.userVerification
|
||||
reference = try WebAuthnSSH.exportReference(credential)
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
.onDisappear { task?.cancel() }
|
||||
SecurityKeyEditorForm(
|
||||
existing: existing, exportReference: WebAuthnSSH.exportReference, validate: WebAuthnSSH.validate,
|
||||
persist: { try credentials.upsert($0) }, close: finish)
|
||||
}
|
||||
|
||||
private func finish() {
|
||||
if let close { close() } else { dismiss() }
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
running = true
|
||||
error = nil
|
||||
defer { running = false }
|
||||
do {
|
||||
guard var credential = originalCredential else { throw TermindSSHError.missingCredential }
|
||||
credential.userVerification = verification
|
||||
try WebAuthnSSH.validate(credential)
|
||||
try Task.checkCancellation()
|
||||
var key = existing
|
||||
key.label = label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
key.colorTag = colorTag
|
||||
key.securityKey = credential
|
||||
try credentials.upsert(key)
|
||||
finish()
|
||||
} catch is CancellationError {
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ private final class KeyExportRenderingPresentation {
|
||||
|
||||
private struct KeyExportRenderingHost: View {
|
||||
let fixture: KeyExportFixture
|
||||
let model: MobileKeyExportModel
|
||||
let model: KeyExportModel
|
||||
@Bindable var presentation: KeyExportRenderingPresentation
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
|
||||
@@ -7,16 +7,6 @@ import Testing
|
||||
|
||||
@Suite @MainActor
|
||||
struct MobileKeyExportTests {
|
||||
@Test func openingAndEmptySelectionNeverInstallAnything() async {
|
||||
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)
|
||||
}
|
||||
|
||||
@Test func searchAndSelectAllOnlyChangeVisibleEligibleHosts() {
|
||||
let fixture = KeyExportFixture()
|
||||
let model = MobileMultiHostPickerModel(
|
||||
@@ -41,77 +31,6 @@ struct MobileKeyExportTests {
|
||||
#expect(!model.allResultsSelected)
|
||||
}
|
||||
|
||||
@Test func missingPublicKeyCannotBeExported() async {
|
||||
let fixture = KeyExportFixture()
|
||||
let recorder = KeyExportRecorder()
|
||||
let model = MobileKeyExportModel(
|
||||
key: SSHKeyRef(label: "Missing"), credentials: fixture.credentials,
|
||||
services: MobileKeyExportServices(install: recorder.install))
|
||||
await model.export(to: [fixture.alpha.id])
|
||||
#expect(await recorder.calls.isEmpty)
|
||||
}
|
||||
|
||||
@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 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 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)
|
||||
#expect(!String(localized: model.summary).isEmpty)
|
||||
}
|
||||
|
||||
@Test func runningExportRejectsDuplicateSubmissions() async {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -137,9 +56,9 @@ struct KeyExportFixture {
|
||||
credentials = CredentialLibraryModel(library: library)
|
||||
}
|
||||
|
||||
func model(_ recorder: KeyExportRecorder) -> MobileKeyExportModel {
|
||||
MobileKeyExportModel(
|
||||
key: key, credentials: credentials, services: MobileKeyExportServices(install: recorder.install))
|
||||
func model(_ recorder: KeyExportRecorder) -> KeyExportModel {
|
||||
KeyExportModel(
|
||||
keys: [key], credentials: credentials, services: KeyExportServices(install: recorder.install))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1528,12 +1528,6 @@ private struct SecureEnclaveKeyEditor: View {
|
||||
/// 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 {
|
||||
private enum ExportState: Equatable {
|
||||
case running
|
||||
case succeeded
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
let keys: [SSHKeyRef]
|
||||
|
||||
@Environment(AppModel.self) private var app
|
||||
@@ -1541,7 +1535,7 @@ struct ExportKeyToHostsSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var chooser = ManagedChooserState<Host.ID>(mode: .multiple)
|
||||
@State private var states: [Host.ID: ExportState] = [:]
|
||||
@State private var exportModel: KeyExportModel?
|
||||
@FocusState private var searchFocused: Bool
|
||||
|
||||
private var query: String { chooser.query }
|
||||
@@ -1552,8 +1546,9 @@ struct ExportKeyToHostsSheet: View {
|
||||
private var eligibleHosts: [Host] {
|
||||
hosts.filter { app.credentials.keyExportUnavailableReason(for: $0) == nil }
|
||||
}
|
||||
private var isExporting: Bool { states.values.contains(.running) }
|
||||
private var isFinished: Bool { !states.isEmpty && !isExporting }
|
||||
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) {
|
||||
@@ -1811,51 +1806,21 @@ struct ExportKeyToHostsSheet: View {
|
||||
}
|
||||
|
||||
private func export() {
|
||||
let targets = app.credentials.searchHosts("").filter {
|
||||
chooser.selected.contains($0.id)
|
||||
}
|
||||
guard !targets.isEmpty else { return }
|
||||
|
||||
var preparations: [(Host.ID, SSHConnectionPreparation)] = []
|
||||
for host in targets {
|
||||
if let reason = app.credentials.keyExportUnavailableReason(for: host) {
|
||||
states[host.id] = .failed(reason)
|
||||
continue
|
||||
}
|
||||
do {
|
||||
preparations.append(
|
||||
(host.id, try app.credentials.prepareSSHConnection(for: host))
|
||||
)
|
||||
states[host.id] = .running
|
||||
} catch {
|
||||
states[host.id] = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
let exportKeys = keys.map {
|
||||
SSHAuthorizedKeyInstall(publicKey: $0.publicKey, name: $0.label)
|
||||
}
|
||||
let connectionCoordinator = app.connections
|
||||
Task {
|
||||
await withTaskGroup(of: (Host.ID, String?).self) { group in
|
||||
for (id, preparation) in preparations {
|
||||
group.addTask {
|
||||
do {
|
||||
let configuration = try await preparation.resolve()
|
||||
try await connectionCoordinator.installAuthorizedKeys(
|
||||
exportKeys, using: configuration
|
||||
)
|
||||
return (id, nil)
|
||||
} catch {
|
||||
return (id, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
for await (id, error) in group {
|
||||
states[id] = error.map(ExportState.failed) ?? .succeeded
|
||||
}
|
||||
}
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,89 +9,14 @@ struct SecurityKeyEditor: View {
|
||||
let existing: SSHKeyRef
|
||||
var close: (() -> Void)? = nil
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var label = ""
|
||||
@State private var colorTag = TagColor.none
|
||||
@State private var verification: SecurityKeyCredential.UserVerification = .preferred
|
||||
@State private var reference = ""
|
||||
@State private var originalCredential: SecurityKeyCredential?
|
||||
@State private var error: String?
|
||||
@State private var running = false
|
||||
@State private var task: Task<Void, Never>?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Form {
|
||||
Section {
|
||||
Label(AppStrings.SecurityKey.title.swiftUIKey, systemImage: "key.radiowaves.forward.fill").font(
|
||||
.headline)
|
||||
VaultNameField(text: $label, prompt: Text(AppStrings.SecurityKey.title))
|
||||
VaultColorField(selection: $colorTag)
|
||||
}
|
||||
Section {
|
||||
LabeledContent(AppStrings.Keychain.storeIn.swiftUIKey) {
|
||||
Label(AppStrings.SecurityKey.title.swiftUIKey, systemImage: "key.radiowaves.forward.fill")
|
||||
}
|
||||
WebAuthnVerificationPicker(selection: $verification)
|
||||
if !reference.isEmpty {
|
||||
Text(AppStrings.SecurityKey.reference).font(.headline)
|
||||
Text(reference).font(.caption.monospaced()).textSelection(.enabled)
|
||||
}
|
||||
Text(existing.publicKey).font(.caption.monospaced()).textSelection(.enabled)
|
||||
}
|
||||
if let error { Text(error).foregroundStyle(.red) }
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
HStack {
|
||||
Button(AppStrings.Common.cancel.swiftUIKey) {
|
||||
task?.cancel()
|
||||
finish()
|
||||
}
|
||||
Spacer()
|
||||
if running { ProgressView().controlSize(.small) }
|
||||
Button(AppStrings.Common.save.swiftUIKey) {
|
||||
task = Task { await save() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(running || originalCredential == nil)
|
||||
}.padding()
|
||||
}
|
||||
.task {
|
||||
label = existing.label
|
||||
colorTag = existing.colorTag
|
||||
do {
|
||||
guard let credential = existing.securityKey else {
|
||||
throw TermindSSHError.missingCredential
|
||||
}
|
||||
originalCredential = credential
|
||||
verification = credential.userVerification
|
||||
reference = try WebAuthnSSH.exportReference(credential)
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
.onDisappear { task?.cancel() }
|
||||
SecurityKeyEditorForm(
|
||||
existing: existing, exportReference: WebAuthnSSH.exportReference, validate: WebAuthnSSH.validate,
|
||||
persist: { try credentials.upsert($0) }, close: finish)
|
||||
}
|
||||
|
||||
private func finish() {
|
||||
if let close { close() } else { dismiss() }
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
running = true
|
||||
error = nil
|
||||
defer { running = false }
|
||||
do {
|
||||
guard var credential = originalCredential else { throw TermindSSHError.missingCredential }
|
||||
credential.userVerification = verification
|
||||
try WebAuthnSSH.validate(credential)
|
||||
try Task.checkCancellation()
|
||||
var key = existing
|
||||
key.label = label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
key.colorTag = colorTag
|
||||
key.securityKey = credential
|
||||
try credentials.upsert(key)
|
||||
finish()
|
||||
} catch is CancellationError {
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -392,10 +392,7 @@ final class SFTPChannelModel: Identifiable {
|
||||
{
|
||||
credentials.append(
|
||||
stored.sshCredential(
|
||||
publicKey: key.publicKey, certificate: key.certificate,
|
||||
passphraseOverride: suppliedAuthentication.passphraseForConnection(),
|
||||
requiresPassphrase: key.requiresPassphraseEntry,
|
||||
requiresUserPresence: key.requiresUserPresence, keyName: key.label
|
||||
for: key, passphraseOverride: suppliedAuthentication.passphraseForConnection()
|
||||
))
|
||||
}
|
||||
if let password = suppliedAuthentication.passwordForConnection() {
|
||||
|
||||
@@ -459,10 +459,7 @@ final class SSHSessionModel: Identifiable {
|
||||
if let credential = try store.connectionKeySecretCredential(for: key) {
|
||||
credentials.append(
|
||||
credential.sshCredential(
|
||||
publicKey: key.publicKey, certificate: key.certificate,
|
||||
passphraseOverride: suppliedAuthentication.passphraseForConnection(),
|
||||
requiresPassphrase: key.requiresPassphraseEntry,
|
||||
requiresUserPresence: key.requiresUserPresence, keyName: key.label
|
||||
for: key, passphraseOverride: suppliedAuthentication.passphraseForConnection()
|
||||
)
|
||||
)
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user