Files
RouterOS/RouterOSAssistant/Core/Services/KeychainService.swift
T
KayandClaude Sonnet 5 9de9311bee M26+M27: Seriennummer bei Bekannte Router, Schlüsselbund-Trennung, terse-Fallback verallgemeinert
M26: SavedRouter bekommt ein optionales serialNumber-Feld. Zwei
physisch verschiedene Router mit identischem Host+Benutzername
(Werks-Adresse) blieben bisher ein gemeinsamer "Bekannte Router"-
Eintrag; recordSuccessfulConnection matcht jetzt zusätzlich nach
Seriennummer, mit sauberer Migration bestehender Einträge ohne
Seriennummer. Drei neue Tests.

M27, beim Live-Test von M26 gefunden:
- Passwort-Anzeige-Button (Augen-Symbol) im Verbinden-Tab
- Bug 35: KeychainService speicherte Passwörter nur nach
  "username@host" — beide Router teilten sich denselben
  Schlüsselbund-Eintrag trotz getrennter SavedRouter-Einträge. Fix:
  optionaler serialNumber-Parameter qualifiziert den Account-Key,
  mit Fallback auf den alten Key für bereits gespeicherte Passwörter.
- Bug 36: neuer Router zeigte keine Routerboard-Infos/Seriennummer —
  derselbe Root Cause wie der zuvor gemeldete "Jetzt prüfen"-Fehler:
  /system routerboard und /system package update scheitern auf diesem
  Router mit "expected end of command" statt dem bisher einzig
  abgefangenen "bad parameter terse". SSHTransport.fetchMenuItems
  erkennt jetzt beide Formulierungen.

Alles live bestätigt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 14:02:27 +02:00

67 lines
3.1 KiB
Swift

import Foundation
import Security
/// Stores router login passwords in the macOS Keychain, never in plaintext.
///
/// Account keys are `"username@host"`, optionally suffixed `"#<serialNumber>"` when the caller
/// knows which physical device it is. Without the serial, two different routers left at
/// MikroTik's factory default (192.168.88.1/admin) share one Keychain entry — confirmed live,
/// 2026-09-16: switching between two "Bekannte Router" entries for such devices (already
/// correctly kept separate by `SavedRoutersStore` since M26) kept loading the *same* remembered
/// password for both, since this store never looked past host+username. `loadPassword` still
/// falls back to the plain, unqualified key when no serial-qualified entry exists yet, so
/// passwords saved before this change (or for a device with no routerboard/serial at all) keep
/// working.
struct KeychainService {
private let service = "com.focus72.RouterOSAssistant"
private func account(host: String, username: String, serialNumber: String?) -> String {
guard let serialNumber else { return "\(username)@\(host)" }
return "\(username)@\(host)#\(serialNumber)"
}
func save(password: String, forHost host: String, username: String, serialNumber: String? = nil) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account(host: host, username: username, serialNumber: serialNumber)
]
SecItemDelete(query as CFDictionary)
var attributes = query
attributes[kSecValueData as String] = Data(password.utf8)
SecItemAdd(attributes as CFDictionary, nil)
}
func loadPassword(forHost host: String, username: String, serialNumber: String? = nil) -> String? {
if let serialNumber,
let password = loadPassword(account: account(host: host, username: username, serialNumber: serialNumber)) {
return password
}
return loadPassword(account: account(host: host, username: username, serialNumber: nil))
}
private func loadPassword(account: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
func deletePassword(forHost host: String, username: String, serialNumber: String? = nil) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account(host: host, username: username, serialNumber: serialNumber)
]
SecItemDelete(query as CFDictionary)
}
}