Remove Host Key Changed

This commit is contained in:
Kaede Akino committed 2026-09-22 01:28:47 +08:00
1 parent 90fb1bfbcc
commit 762e6b1b57
21 files changed
+112 -139

No files matched your search

@@ -72,6 +72,8 @@ struct PersistenceCompatibilityTests {
#expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV26.versionIdentifier })
#expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV27.versionIdentifier })
#expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV28.versionIdentifier })
#expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV29.versionIdentifier })
#expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV30.versionIdentifier })
#expect(TermindSchemaV22.models.contains { $0 == TermindSchemaV22HostModels.PersistentHost.self })
#expect(!TermindSchemaV22.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self })
#expect(TermindSchemaV25.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self })
@@ -86,6 +88,34 @@ struct PersistenceCompatibilityTests {
#expect(first.schema.entities.contains { $0.name == "PersistentSFTPPreferences" })
}
@Test
func version29StoreDropsMismatchStateWithoutLosingKnownHosts() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("Termind.store")
let oldSchema = Schema(versionedSchema: TermindSchemaV29.self)
let oldConfiguration = ModelConfiguration(
"Termind", schema: oldSchema, url: url, cloudKitDatabase: .none
)
let knownHost = KnownHostEntry(
hostname: "server.invalid", algorithm: .ed25519,
fingerprint: "SHA256:known-host"
)
var oldContainer: ModelContainer? = try ModelContainer(
for: oldSchema, configurations: [oldConfiguration]
)
let oldRecord = TermindSchemaV29KnownHostModels.PersistentKnownHost(knownHost)
oldRecord.isMismatched = true
oldContainer?.mainContext.insert(oldRecord)
try oldContainer?.mainContext.save()
oldContainer = nil
let migrated = try TermindPersistence.makeContainer(url: url)
let records = try migrated.mainContext.fetch(FetchDescriptor<PersistentKnownHost>())
#expect(records.map(\.value) == [knownHost])
}
@Test
func version25StoreMigratesWithNoStartupSnippet() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
@@ -12242,22 +12242,6 @@
}
}
},
"Host key changed" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "主机密钥已更改"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "主機金鑰已更改"
}
}
}
},
"Host key confirmation required" : {
"extractionState" : "stale",
"localizations" : {
@@ -131,12 +131,6 @@ public final class RemoteSessionConnectionController {
return .accept
}
finishHostKeyAuthorization(.reject)
if !challenge.expectedHostKeys.isEmpty {
store.markKnownHostMismatched(
hostname: challenge.observation.hostname,
port: challenge.observation.port
)
}
let authorizationID = UUID()
hostKeyAuthorizationID = authorizationID
waitForHostKey(
@@ -64,7 +64,6 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable {
func setConnectionStatus(_ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID)
func recordRemoteSystemInfo(_ systemInfo: RemoteSystemInfo?, for hostID: Host.ID)
func trust(_ entry: KnownHostEntry)
func markKnownHostMismatched(hostname: String, port: Int)
@discardableResult func upsert(_ identity: Identity) -> Bool
func upsert(_ identity: Identity, passwordUpdate: IdentityPasswordUpdate) throws
func upsert(_ host: Host)
@@ -408,9 +408,6 @@ public final class HostLibraryModel: RemoteSessionHostStore {
guard !knownHosts.isEmpty else {
return String(localized: "Connect once to trust this host")
}
guard !knownHosts.contains(where: \.isMismatched) else {
return String(localized: "Host key changed")
}
return nil
}
@@ -1218,9 +1215,6 @@ public final class HostLibraryModel: RemoteSessionHostStore {
let endpointMatches: (KnownHostEntry) -> Bool = {
$0.matches(hostname: entry.hostname, port: entry.port)
}
for index in knownHosts.indices where endpointMatches(knownHosts[index]) {
knownHosts[index].isMismatched = false
}
if let index = knownHosts.firstIndex(where: {
endpointMatches($0) && $0.fingerprint == entry.fingerprint
}) {
@@ -1237,16 +1231,6 @@ public final class HostLibraryModel: RemoteSessionHostStore {
persistCore()
}
public func markKnownHostMismatched(hostname: String, port: Int) {
var changed = false
for index in knownHosts.indices
where knownHosts[index].matches(hostname: hostname, port: port) {
knownHosts[index].isMismatched = true
changed = true
}
if changed { persistCore() }
}
public func setExpanded(_ expanded: Bool, for groupID: HostGroup.ID) {
guard group(groupID) != nil else { return }
expansionState.setHostGroupExpanded(expanded, for: groupID)
@@ -2130,12 +2114,10 @@ public final class HostLibraryModel: RemoteSessionHostStore {
}
let previous = knownHosts
if let index = knownHosts.firstIndex(where: { $0.id == entry.id }) {
// Editing the address or key explicitly creates a new trust decision.
if knownHosts[index].identity != entry.identity || knownHosts[index].hostname != entry.hostname
|| knownHosts[index].port != entry.port
{
entry.addedAt = Date()
entry.isMismatched = false
}
knownHosts[index] = entry
} else {
@@ -969,12 +969,6 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
) -> HostKeyAuthorization? {
guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { return nil }
finishHostKeyAuthorization(for: serverID, decision: .reject)
if !challenge.expectedHostKeys.isEmpty {
hosts.markKnownHostMismatched(
hostname: challenge.observation.hostname,
port: challenge.observation.port
)
}
server.hostKey = challenge.observation
server.hostKeyHostID = challenge.hostID
server.hostKeyPrompt = SSHHostKeyPrompt(
@@ -13,17 +13,13 @@ public nonisolated struct KnownHostEntry: Identifiable, Codable, Hashable, Senda
public var algorithm: SSHKeyAlgorithm = .ed25519
public var fingerprint: String = ""
public var addedAt: Date = Date(timeIntervalSince1970: 0)
/// True once the key the server presented stopped matching the pinned one.
/// Kept as data rather than a transient alert so the warning survives the
/// dialog the user dismissed at 3am.
public var isMismatched: Bool = false
public var identity: SSHHostKeyIdentity {
SSHHostKeyIdentity(algorithm: algorithm, fingerprint: fingerprint)
}
public init(
id: UUID = UUID(), hostname: String, port: Int = 22, algorithm: SSHKeyAlgorithm = .ed25519,
fingerprint: String = "", addedAt: Date = Date(timeIntervalSince1970: 0), isMismatched: Bool = false
fingerprint: String = "", addedAt: Date = Date(timeIntervalSince1970: 0)
) {
self.id = id
self.hostname = hostname
@@ -31,7 +27,6 @@ public nonisolated struct KnownHostEntry: Identifiable, Codable, Hashable, Senda
self.algorithm = algorithm
self.fingerprint = fingerprint
self.addedAt = addedAt
self.isMismatched = isMismatched
}
}
@@ -383,36 +383,26 @@ public enum TermindSchemaV7Models {
}
}
@Model
public final class PersistentKnownHost {
@Attribute(.unique) public var id: UUID = UUID()
public var hostname = ""
public var port = 22
public var algorithm = SSHKeyAlgorithm.ed25519.rawValue
public var fingerprint = ""
public var addedAt = Date(timeIntervalSince1970: 0)
public var isMismatched = false
/// Frozen through V29. V30 removes the persisted mismatch flag from the live model.
public enum TermindSchemaV29KnownHostModels {
@Model
public final class PersistentKnownHost {
@Attribute(.unique) public var id: UUID = UUID()
public var hostname = ""
public var port = 22
public var algorithm = SSHKeyAlgorithm.ed25519.rawValue
public var fingerprint = ""
public var addedAt = Date(timeIntervalSince1970: 0)
public var isMismatched = false
public init(_ value: KnownHostEntry) {
id = value.id
apply(value)
}
public func apply(_ value: KnownHostEntry) {
hostname = value.hostname
port = value.port
algorithm = value.algorithm.rawValue
fingerprint = value.fingerprint
addedAt = value.addedAt
isMismatched = value.isMismatched
}
public var value: KnownHostEntry {
KnownHostEntry(
id: id, hostname: hostname, port: port,
algorithm: SSHKeyAlgorithm(rawValue: algorithm) ?? .ed25519,
fingerprint: fingerprint, addedAt: addedAt, isMismatched: isMismatched
)
public init(_ value: KnownHostEntry) {
id = value.id
hostname = value.hostname
port = value.port
algorithm = value.algorithm.rawValue
fingerprint = value.fingerprint
addedAt = value.addedAt
}
}
}
@@ -503,7 +493,7 @@ public enum TermindSchemaV1: VersionedSchema {
PersistentHostGroup.self,
TermindSchemaV6Models.PersistentIdentity.self,
TermindSchemaV6Models.PersistentSSHKey.self,
PersistentKnownHost.self,
TermindSchemaV29KnownHostModels.PersistentKnownHost.self,
PersistentSnippetPackage.self,
PersistentSnippet.self,
TermindSchemaV19ForwardingModels.PersistentPortForwardingRule.self,
@@ -782,6 +772,15 @@ public enum TermindSchemaV29: VersionedSchema {
}
}
public enum TermindSchemaV30: VersionedSchema {
public static let versionIdentifier = Schema.Version(30, 0, 0)
public static var models: [any PersistentModel.Type] {
TermindSchemaV29.models.filter {
$0 != TermindSchemaV29KnownHostModels.PersistentKnownHost.self
} + [TermindSchemaV30KnownHostModels.PersistentKnownHost.self]
}
}
public enum TermindMigrationPlan: SchemaMigrationPlan {
public static var schemas: [any VersionedSchema.Type] {
[
@@ -793,6 +792,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
TermindSchemaV18.self, TermindSchemaV19.self, TermindSchemaV20.self, TermindSchemaV21.self,
TermindSchemaV22.self, TermindSchemaV23.self, TermindSchemaV24.self, TermindSchemaV25.self,
TermindSchemaV26.self, TermindSchemaV27.self, TermindSchemaV28.self, TermindSchemaV29.self,
TermindSchemaV30.self,
]
}
@@ -863,6 +863,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
MigrationStage.lightweight(fromVersion: TermindSchemaV26.self, toVersion: TermindSchemaV27.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV27.self, toVersion: TermindSchemaV28.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV28.self, toVersion: TermindSchemaV29.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV29.self, toVersion: TermindSchemaV30.self),
]
}
}
@@ -870,3 +871,4 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
public typealias PersistentHost = TermindSchemaV29HostModels.PersistentHost
public typealias PersistentIdentity = TermindSchemaV25IdentityModels.PersistentIdentity
public typealias PersistentSSHKey = TermindSchemaV7Models.PersistentSSHKey
public typealias PersistentKnownHost = TermindSchemaV30KnownHostModels.PersistentKnownHost
@@ -5,7 +5,7 @@ import SwiftData
/// choose storage and own the container lifetime, never the model set.
@MainActor
public enum TermindPersistence {
public static let schema = Schema(versionedSchema: TermindSchemaV29.self)
public static let schema = Schema(versionedSchema: TermindSchemaV30.self)
public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer {
precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL")
@@ -0,0 +1,37 @@
import Foundation
import SwiftData
import TermindDomain
/// V30 removes the historical host-key mismatch marker while preserving Known Host trust records.
public enum TermindSchemaV30KnownHostModels {
@Model
public final class PersistentKnownHost {
@Attribute(.unique) public var id: UUID = UUID()
public var hostname = ""
public var port = 22
public var algorithm = SSHKeyAlgorithm.ed25519.rawValue
public var fingerprint = ""
public var addedAt = Date(timeIntervalSince1970: 0)
public init(_ value: KnownHostEntry) {
id = value.id
apply(value)
}
public func apply(_ value: KnownHostEntry) {
hostname = value.hostname
port = value.port
algorithm = value.algorithm.rawValue
fingerprint = value.fingerprint
addedAt = value.addedAt
}
public var value: KnownHostEntry {
KnownHostEntry(
id: id, hostname: hostname, port: port,
algorithm: SSHKeyAlgorithm(rawValue: algorithm) ?? .ed25519,
fingerprint: fingerprint, addedAt: addedAt
)
}
}
}
@@ -380,12 +380,6 @@ public final class AgentHostResolver: Sendable {
?? (challenge.observation.port == 22
? challenge.observation.hostname
: "\(challenge.observation.hostname):\(challenge.observation.port)")
if !challenge.expectedHostKeys.isEmpty {
store.markKnownHostMismatched(
hostname: challenge.observation.hostname,
port: challenge.observation.port
)
}
let response = await interaction.confirmHostKey(
AgentSSHHostKeyRequest(
invocationID: invocationID, hostID: challenge.hostID,
+1 -1
View File
@@ -77,7 +77,7 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r
- SSH import previews are window-owned indexed public metadata. Host selection never selects credentials; imported
identities require selected keys. The actor retains scoped descriptors/digests, not private-key text/passphrases;
reread selected keys at unlock/commit and fail closed if changed. Picking/drop share validation; config parsing
executes nothing. Create credentials before one Vault save, clean up failure, never overwrite records/clear mismatch.
executes nothing. Create credentials before one Vault save, clean up failure, and never overwrite records.
- macOS appearance/language are local; System appearance clears `NSApplication.appearance`. Login launch reads
`SMAppService`, not mirrored preferences; quiet launch hides the app without suppressing its window. Mobile uses
Apple's per-app language settings without an injected locale override.
-4
View File
@@ -572,10 +572,6 @@ struct MobileVaultView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack(spacing: 6) {
if app.library.knownHosts(for: host).contains(where: \.isMismatched) {
MobileCardChip(
title: "Host key changed", symbol: "exclamationmark.shield", tint: .orange)
}
ForEach(MobileCardLayout.visibleTags(host.tags), id: \.self) { tag in
MobileCardChip(text: tag)
}
+1 -2
View File
@@ -36,8 +36,7 @@ struct MobileCardRenderingTests {
if index == 3 {
_ = app.knownHosts.save(
KnownHostEntry(
hostname: host.host, fingerprint: "SHA256:0123456789abcdefghijklmnopqrstuvwxyzABCDE",
isMismatched: true))
hostname: host.host, fingerprint: "SHA256:0123456789abcdefghijklmnopqrstuvwxyzABCDE"))
}
}
let editableKnownHost = KnownHostEntry(
@@ -174,13 +174,10 @@ extension HostLibraryModel {
fingerprint: "SHA256:2mF8sK1pQ7xR4vB9nL3yT6wC0dH5jA2uZgE7iOxNbVr",
addedAt: now.addingTimeInterval(-604_800)
),
// One deliberately broken pin, so the warning state is visible at P0
// instead of first appearing the day it matters.
KnownHostEntry(
hostname: "staging.internal", algorithm: .rsa,
fingerprint: "SHA256:5vT2nB8xQ1mL7kR4pW9yC3sD6fH0jG5uZaE8iXoNbVr",
addedAt: now.addingTimeInterval(-15_552_000),
isMismatched: true
addedAt: now.addingTimeInterval(-15_552_000)
),
]
@@ -123,9 +123,6 @@ extension TransferLibraryModel: RemoteSessionHostStore {
library.recordRemoteSystemInfo(systemInfo, for: hostID)
}
func trust(_ entry: KnownHostEntry) { library.trust(entry) }
func markKnownHostMismatched(hostname: String, port: Int) {
library.markKnownHostMismatched(hostname: hostname, port: port)
}
@discardableResult func upsert(_ identity: Identity) -> Bool { library.upsert(identity) }
func upsert(_ identity: Identity, passwordUpdate: IdentityPasswordUpdate) throws {
try library.upsert(identity, passwordUpdate: passwordUpdate)
-14
View File
@@ -421,13 +421,6 @@ private struct HostCard: View {
private var status: ConnectionStatus { app.hostLibrary.status(of: host.id) }
/// A pinned key that stopped matching. Shown here, on the host, because that
/// is what the fingerprint belongs to — there is no separate Known Hosts
/// list to go and check.
private var keyMismatched: Bool {
app.hostLibrary.knownHosts(for: host).contains(where: \.isMismatched)
}
var body: some View {
card.clearAllHostHistoryConfirmation(hostIDs: $clearHistoryHostIDs)
}
@@ -486,13 +479,6 @@ private struct HostCard: View {
private var footer: some View {
HStack(spacing: Metrics.Space.xs) {
if keyMismatched {
CardChip(
text: String(localized: "Host key changed"),
symbol: "exclamationmark.triangle.fill",
tint: .orange
)
}
ForEach(host.tags.prefix(2), id: \.self) { tag in
CardChip(text: tag)
}
@@ -864,7 +864,6 @@ struct AgentHostAuthenticationTests {
#expect(decision == .accept)
#expect(attempt.configuration.expectedHostKeys == [old.identity])
#expect(Set(store.knownHosts(for: host).map(\.identity)) == [old.identity, observation.identity])
#expect(store.knownHosts(for: host).allSatisfy { !$0.isMismatched })
}
@Test
+3 -13
View File
@@ -278,12 +278,11 @@ struct ConnectionJourneyTests {
}
@Test
func trustingTheSameFingerprintIsIdempotentAndClearsMismatch() {
func trustingTheSameFingerprintIsIdempotent() {
let host = Host(name: "Server", host: "server.example")
let original = KnownHostEntry(
hostname: host.host, algorithm: .ed25519,
fingerprint: "SHA256:key", addedAt: Date(timeIntervalSince1970: 1),
isMismatched: true
fingerprint: "SHA256:key", addedAt: Date(timeIntervalSince1970: 1)
)
let store = HostLibraryModel(hosts: [host], knownHosts: [original])
@@ -298,28 +297,19 @@ struct ConnectionJourneyTests {
#expect(entries.count == 1)
#expect(entries[0].id == original.id)
#expect(entries[0].addedAt == Date(timeIntervalSince1970: 2))
#expect(!entries[0].isMismatched)
}
@Test
func mismatchAndForgetOperationsApplyToTheHostKeySet() {
func trustAndForgetOperationsApplyToTheHostKeySet() {
let host = Host(name: "Server", host: "server.example")
let first = KnownHostEntry(hostname: host.host, fingerprint: "SHA256:first")
let second = KnownHostEntry(hostname: host.host, fingerprint: "SHA256:second")
let store = HostLibraryModel(hosts: [host], knownHosts: [first, second])
store.markKnownHostMismatched(hostname: host.host, port: host.port)
for entry in store.knownHosts(for: host) {
#expect(entry.isMismatched)
}
store.trust(
KnownHostEntry(hostname: host.host, fingerprint: "SHA256:third")
)
#expect(store.knownHosts(for: host).count == 3)
for entry in store.knownHosts(for: host) {
#expect(!entry.isMismatched)
}
store.forget(knownHost: first.id)
#expect(store.knownHosts(for: host).count == 2)
+3 -5
View File
@@ -56,8 +56,6 @@ struct SSHImportTests {
let store = HostLibraryModel(knownHosts: [decoded])
let host = Host(name: "IPv6", host: "2001:db8::1", port: 2222)
#expect(try store.expectedHostKeys(for: host) == [entry.identity])
store.markKnownHostMismatched(hostname: host.host, port: host.port)
#expect(store.knownHosts[0].isMismatched)
store.forgetKnownHosts(for: host)
#expect(store.knownHosts.isEmpty)
}
@@ -427,8 +425,8 @@ struct SSHImportTests {
}
@Test
func importRetainsMismatchWarningsAndRequiresConflictReview() async {
let original = KnownHostEntry(hostname: "host", fingerprint: "SHA256:old", isMismatched: true)
func importRequiresKnownHostConflictReview() async {
let original = KnownHostEntry(hostname: "host", fingerprint: "SHA256:old")
let store = HostLibraryModel(knownHosts: [original])
let model = SSHImportModel(library: store, reader: SSHImportReader())
model.items = [
@@ -441,7 +439,7 @@ struct SSHImportTests {
#expect(model.canImport)
await model.commit()
#expect(model.completed)
#expect(store.knownHosts.first?.isMismatched == true)
#expect(store.knownHosts.first?.fingerprint == original.fingerprint)
}
@Test
+2 -2
View File
@@ -115,10 +115,10 @@ struct VaultSyncTests {
#expect(Set(received.knownHosts) == Set([plain, hashed]))
var edited = plain
edited.isMismatched = true
edited.algorithm = .ecdsa256
#expect(library.saveKnownHost(edited))
try transfer(plain.id)
#expect(try destinationPersistence.load().knownHosts.first { $0.id == plain.id }?.isMismatched == true)
#expect(try destinationPersistence.load().knownHosts.first { $0.id == plain.id }?.algorithm == .ecdsa256)
#expect(library.deleteKnownHosts([plain.id]))
try transfer(plain.id)