refactor(ssh): share key workflows and wire encoding

This commit is contained in:
Kaede Akino committed 2026-09-29 00:05:32 +08:00
1 parent 816a6ff121
commit 565ef17d87
27 files changed
+575 -413

No files matched your search

+214
View File
@@ -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" }
}
}
+19
View File
@@ -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 {
@@ -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.
+2 -2
View File
@@ -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 {
+3 -78
View File
@@ -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
}
}
}
+1 -1
View File
@@ -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
+3 -84
View File
@@ -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))
}
}
+18 -53
View File
@@ -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) }
}
}
+3 -78
View File
@@ -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
}
}
}
+1 -4
View File
@@ -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() {
+1 -4
View File
@@ -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 {