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:
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user