M7: SSH-Hostkey-TOFU (ersetzt .acceptAnything())

Größte offene Härtungslücke geschlossen: SSHTransport nutzte bisher
.acceptAnything() für Host-Key-Validierung, akzeptierte also jeden
Schlüssel ohne Prüfung -- ein Man-in-the-Middle im lokalen Netz wäre
unbemerkt geblieben. Jetzt Trust-on-first-use wie beim REST-Zertifikat:

- SSHHostKeyFingerprint: SHA256 über NIOSSHPublicKey.write(to:) (die
  SSH-Wire-Format-Bytes des Schlüssels) -- exakt die Bytes, die auch
  OpenSSH für seine SHA256:-Fingerabdrücke hasht. Per Unit-Test gegen
  einen echten ssh-keygen-erzeugten Testschlüssel kreuzgeprüft
  (SHA256:Hllxv6LLoHl2XTIXGGjUYJHbPFoH2F7iMrR74C5J95g), nicht geraten.
- SSHHostKeyTrustStore: UserDefaults-Persistenz pro Host, Pendant zu
  CertificateTrustStore.
- SSHTransport conformt jetzt selbst zu NIOSSHClientServerAuthentication-
  Delegate (wie RestTransport zu URLSessionDelegate) und übergibt sich
  selbst als .custom(self) Host-Key-Validator.
- ConnectionService: neuer State .needsSSHHostKeyConfirmation, eigener
  Bestätigungs-Retry-Pfad (trustCurrentSSHHostKeyAndRetry), analog zum
  bestehenden Zertifikat-Flow.
- ConnectView: zweiter Bestätigungsdialog mit Warnhinweis, dass ein
  geänderter Fingerabdruck bei zuvor schon verbundenen Routern auf ein
  manipuliertes Netzwerk hindeuten könnte.

BackupService/FactoryResetService bekommen die TOFU-Prüfung automatisch
mit (SSHTransport-Default-Parameter, gleicher UserDefaults-Speicher),
ohne eigene Bestätigungs-UI -- in der Praxis unkritisch, da der
Verbinden-Tab das Vertrauen immer zuerst herstellt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HReLXMbmPvtQ23p1iWiJNW
This commit is contained in:
Kay
2026-09-13 00:40:26 +02:00
co-authored by Claude Sonnet 5
parent 9e6a38a5c5
commit 13344c265e
9 changed files with 167 additions and 10 deletions
@@ -29,6 +29,7 @@ enum RouterOSError: LocalizedError, Equatable {
case invalidResponse(String)
case authenticationFailed
case untrustedCertificate(fingerprint: String)
case untrustedSSHHostKey(fingerprint: String)
case transportUnavailable(String)
var errorDescription: String? {
@@ -41,6 +42,8 @@ enum RouterOSError: LocalizedError, Equatable {
return "Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen."
case .untrustedCertificate(let fingerprint):
return "Unbekanntes Zertifikat (Fingerabdruck \(fingerprint)). Bitte bestätigen."
case .untrustedSSHHostKey(let fingerprint):
return "Unbekannter SSH-Schlüssel (Fingerabdruck \(fingerprint)). Bitte bestätigen."
case .transportUnavailable(let detail):
return detail
}
@@ -0,0 +1,16 @@
import Foundation
import NIOCore
import NIOSSH
import CryptoKit
enum SSHHostKeyFingerprint {
/// SHA256 over the key's SSH wire-format encoding (`NIOSSHPublicKey.write(to:)`) the
/// same bytes OpenSSH itself hashes for its `SHA256:...` host key fingerprints.
static func sha256(of key: NIOSSHPublicKey) -> String {
var buffer = ByteBuffer()
_ = key.write(to: &buffer)
let bytes = buffer.readableBytesView
let digest = SHA256.hash(data: Data(bytes))
return digest.map { String(format: "%02X", $0) }.joined(separator: ":")
}
}
@@ -0,0 +1,22 @@
import Foundation
/// Trust-on-first-use store for SSH host keys the SSH-side counterpart to
/// CertificateTrustStore (REST's TLS certificates).
final class SSHHostKeyTrustStore {
private let defaults = UserDefaults.standard
private let key = "RouterOSAssistant.TrustedSSHHostKeyFingerprints"
func isTrusted(host: String, fingerprint: String) -> Bool {
trustedFingerprints()[host] == fingerprint
}
func trust(host: String, fingerprint: String) {
var all = trustedFingerprints()
all[host] = fingerprint
defaults.set(all, forKey: key)
}
private func trustedFingerprints() -> [String: String] {
defaults.dictionary(forKey: key) as? [String: String] ?? [:]
}
}
@@ -1,20 +1,19 @@
import Foundation
import Citadel
import NIOCore
import NIOSSH
/// SSH+CLI transport fallback for RouterOS devices/firmware without the REST API (pre-7.1).
///
/// Known limitation (tracked for M7 hardening): host key validation currently accepts any key.
/// This is acceptable for now because REST already provides certificate TOFU on the primary
/// path and this fallback is used for local-network devices only, but it should get the same
/// trust-on-first-use treatment before wider distribution.
final class SSHTransport: RouterOSTransport {
let kind: RouterOSTransportKind = .ssh
private let credentials: RouterOSCredentials
private let hostKeyTrust: SSHHostKeyTrustStore
private var client: SSHClient?
init(credentials: RouterOSCredentials) {
init(credentials: RouterOSCredentials, hostKeyTrust: SSHHostKeyTrustStore = SSHHostKeyTrustStore()) {
self.credentials = credentials
self.hostKeyTrust = hostKeyTrust
}
func connect() async throws {
@@ -23,13 +22,15 @@ final class SSHTransport: RouterOSTransport {
host: credentials.host,
port: credentials.sshPort,
authenticationMethod: .passwordBased(username: credentials.username, password: credentials.password),
hostKeyValidator: .acceptAnything(),
hostKeyValidator: .custom(self),
reconnect: .never,
// RouterOS' SSH server typically only offers legacy algorithms
// (diffie-hellman-group14-sha1 key exchange, RSA host keys) that
// Citadel's defaults don't include `.all` adds them.
algorithms: .all
)
} catch let error as RouterOSError {
throw error
} catch {
// NIOSSHError's `.localizedDescription` is a useless generic NSError-bridged string
// ("The operation couldn't be completed."); its real diagnostics only surface via
@@ -102,3 +103,14 @@ final class SSHTransport: RouterOSTransport {
}
}
}
extension SSHTransport: NIOSSHClientServerAuthenticationDelegate {
func validateHostKey(hostKey: NIOSSHPublicKey, validationCompletePromise: EventLoopPromise<Void>) {
let fingerprint = SSHHostKeyFingerprint.sha256(of: hostKey)
if hostKeyTrust.isTrusted(host: credentials.host, fingerprint: fingerprint) {
validationCompletePromise.succeed(())
} else {
validationCompletePromise.fail(RouterOSError.untrustedSSHHostKey(fingerprint: fingerprint))
}
}
}
@@ -8,6 +8,7 @@ final class ConnectionService: ObservableObject {
case connecting
case connected(kind: RouterOSTransportKind)
case needsCertificateConfirmation(fingerprint: String)
case needsSSHHostKeyConfirmation(fingerprint: String)
case failed(String)
}
@@ -19,10 +20,15 @@ final class ConnectionService: ObservableObject {
@Published private(set) var credentials: RouterOSCredentials?
private let certificateTrust: CertificateTrustStore
private let sshHostKeyTrust: SSHHostKeyTrustStore
private var activeTransport: RouterOSTransport?
init(certificateTrust: CertificateTrustStore = CertificateTrustStore()) {
init(
certificateTrust: CertificateTrustStore = CertificateTrustStore(),
sshHostKeyTrust: SSHHostKeyTrustStore = SSHHostKeyTrustStore()
) {
self.certificateTrust = certificateTrust
self.sshHostKeyTrust = sshHostKeyTrust
}
/// Injection point for tests: bypasses the real REST/SSH transports.
@@ -46,6 +52,8 @@ final class ConnectionService: ObservableObject {
do {
try await ssh.connect()
await finishConnecting(using: ssh)
} catch RouterOSError.untrustedSSHHostKey(let fingerprint) {
state = .needsSSHHostKeyConfirmation(fingerprint: fingerprint)
} catch {
state = .failed(error.localizedDescription)
}
@@ -55,7 +63,7 @@ final class ConnectionService: ObservableObject {
await connect(
with: credentials,
makeRestTransport: { RestTransport(credentials: credentials, certificateTrust: self.certificateTrust) },
makeSSHTransport: { SSHTransport(credentials: credentials) }
makeSSHTransport: { SSHTransport(credentials: credentials, hostKeyTrust: self.sshHostKeyTrust) }
)
}
@@ -65,6 +73,12 @@ final class ConnectionService: ObservableObject {
await connect(with: credentials)
}
func trustCurrentSSHHostKeyAndRetry(fingerprint: String) async {
guard let credentials else { return }
sshHostKeyTrust.trust(host: credentials.host, fingerprint: fingerprint)
await connect(with: credentials)
}
/// Applies a single configuration change on the active transport (REST or SSH).
func apply(_ command: RouterOSCommand) async throws {
guard let activeTransport else { throw RouterOSError.notConnected }
@@ -54,6 +54,18 @@ struct ConnectView: View {
} message: { fingerprint in
Text("Der Router hat sich mit einem unbekannten Zertifikat gemeldet.\nFingerabdruck: \(fingerprint)\n\nNur bestätigen, wenn dies dein eigenes Gerät im lokalen Netzwerk ist.")
}
.alert(
"Unbekannter SSH-Schlüssel",
isPresented: sshHostKeyAlertBinding,
presenting: sshHostKeyFingerprint
) { fingerprint in
Button("Vertrauen und verbinden") {
viewModel.trustSSHHostKeyAndRetry(fingerprint: fingerprint)
}
Button("Abbrechen", role: .cancel) {}
} message: { fingerprint in
Text("Der Router hat sich mit einem unbekannten SSH-Schlüssel gemeldet.\nFingerabdruck: \(fingerprint)\n\nNur bestätigen, wenn dies dein eigenes Gerät im lokalen Netzwerk ist. Falls du diesen Router schon einmal verbunden hattest und sich der Fingerabdruck geändert hat, könnte das auf ein manipuliertes Netzwerk hindeuten — im Zweifel nicht bestätigen.")
}
.alert(
"Sicherung fehlgeschlagen",
isPresented: Binding(
@@ -82,10 +94,24 @@ struct ConnectView: View {
)
}
private var sshHostKeyFingerprint: String? {
if case .needsSSHHostKeyConfirmation(let fingerprint) = connectionService.state {
return fingerprint
}
return nil
}
private var sshHostKeyAlertBinding: Binding<Bool> {
Binding(
get: { sshHostKeyFingerprint != nil },
set: { _ in }
)
}
@ViewBuilder
private var statusSection: some View {
switch connectionService.state {
case .idle, .needsCertificateConfirmation:
case .idle, .needsCertificateConfirmation, .needsSSHHostKeyConfirmation:
EmptyView()
case .connecting:
Section {
@@ -35,4 +35,10 @@ final class ConnectViewModel: ObservableObject {
await connectionService.trustCurrentCertificateAndRetry(fingerprint: fingerprint)
}
}
func trustSSHHostKeyAndRetry(fingerprint: String) {
Task {
await connectionService.trustCurrentSSHHostKeyAndRetry(fingerprint: fingerprint)
}
}
}
@@ -0,0 +1,35 @@
import XCTest
import NIOSSH
@testable import RouterOSAssistant
final class SSHHostKeyFingerprintTests: XCTestCase {
/// Cross-checked against `ssh-keygen -lf` on the same throwaway test key, which reported
/// `SHA256:Hllxv6LLoHl2XTIXGGjUYJHbPFoH2F7iMrR74C5J95g` confirms our hex fingerprint is
/// SHA256 over the same bytes OpenSSH hashes (the key's SSH wire-format encoding).
func testFingerprintMatchesOpenSSHsSHA256OverTheKeyBlob() throws {
let openSSHLine = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIINj+h+IeiBNELAG6CcYbMxrdsSn8hnccQrk/XDwUa6U test"
let key = try NIOSSHPublicKey(openSSHPublicKey: openSSHLine)
let hexFingerprint = SSHHostKeyFingerprint.sha256(of: key)
let expectedBase64 = "Hllxv6LLoHl2XTIXGGjUYJHbPFoH2F7iMrR74C5J95g"
let digestBytes = hexFingerprint
.split(separator: ":")
.map { UInt8($0, radix: 16)! }
let actualBase64 = Data(digestBytes).base64EncodedString()
.replacingOccurrences(of: "=", with: "")
XCTAssertEqual(actualBase64, expectedBase64)
}
func testDifferentKeysProduceDifferentFingerprints() throws {
let keyA = try NIOSSHPublicKey(
openSSHPublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIINj+h+IeiBNELAG6CcYbMxrdsSn8hnccQrk/XDwUa6U test"
)
let keyB = try NIOSSHPublicKey(
openSSHPublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKFrnJfhSkd4VsrAhMBxc1MS6dm2LrDMMerNh3O4zl95 test2"
)
XCTAssertNotEqual(SSHHostKeyFingerprint.sha256(of: keyA), SSHHostKeyFingerprint.sha256(of: keyB))
}
}
@@ -0,0 +1,23 @@
import XCTest
@testable import RouterOSAssistant
final class SSHHostKeyTrustStoreTests: XCTestCase {
override func tearDown() {
UserDefaults.standard.removeObject(forKey: "RouterOSAssistant.TrustedSSHHostKeyFingerprints")
super.tearDown()
}
func testUntrustedByDefault() {
let store = SSHHostKeyTrustStore()
XCTAssertFalse(store.isTrusted(host: "192.0.2.1", fingerprint: "AA:BB"))
}
func testTrustPersistsForSameHostAndFingerprint() {
let store = SSHHostKeyTrustStore()
store.trust(host: "192.0.2.1", fingerprint: "AA:BB")
XCTAssertTrue(store.isTrusted(host: "192.0.2.1", fingerprint: "AA:BB"))
XCTAssertFalse(store.isTrusted(host: "192.0.2.1", fingerprint: "CC:DD"))
XCTAssertFalse(store.isTrusted(host: "192.0.2.2", fingerprint: "AA:BB"))
}
}