Merge pull request 'feat: provisioner hardening — multi-password auth & structured errors' (#2) from schwifty/provisioner-hardening into main

This commit is contained in:
schwifty 2026-03-21 21:44:06 +00:00
commit 4618df9191
3 changed files with 167 additions and 33 deletions

View file

@ -141,6 +141,43 @@ class Api {
}
}
/// Get a single business by ID
func getBusiness(businessId: Int) async throws -> Business? {
let json = try await postRequest(
endpoint: "/businesses/get.php",
body: ["BusinessID": businessId],
extraHeaders: ["X-Business-Id": String(businessId)]
)
guard parseBool(json["OK"] ?? json["ok"]) else {
return nil
}
guard let b = (json["BUSINESS"] ?? json["business"]) as? [String: Any],
let id = parseIntValue(b["BusinessID"] ?? b["BUSINESSID"]) else {
return nil
}
let name = ((b["BusinessName"] ?? b["BUSINESSNAME"] ?? b["Name"] ?? b["NAME"]) as? String) ?? ""
let headerImageExtension = (b["HeaderImageExtension"] ?? b["HEADERIMAGEEXTENSION"]) as? String
return Business(businessId: id, name: name, headerImageExtension: headerImageExtension)
}
// Business name cache (matches Android's caching behavior)
private var businessNameCache: [Int: String] = [:]
/// Get business name with caching (avoids repeated API calls)
func getBusinessName(businessId: Int) async throws -> String {
if let cached = businessNameCache[businessId] {
return cached
}
if let business = try await getBusiness(businessId: businessId) {
businessNameCache[business.businessId] = business.name
return business.name
}
return ""
}
// =========================================================================
// BEACONS
// =========================================================================
@ -411,7 +448,7 @@ class Api {
)
// Debug log
print("[API] allocateBusinessNamespace response: \(json)")
DebugLog.shared.log("[API] allocateBusinessNamespace response: \(json)")
if !parseBool(json["OK"] ?? json["ok"]) {
let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to allocate namespace"
@ -436,6 +473,28 @@ class Api {
)
}
/// Allocate a minor value for a service point (auto-assigns next available minor)
func allocateServicePointMinor(businessId: Int, servicePointId: Int) async throws -> UInt16 {
let json = try await postRequest(
endpoint: "/beacon-sharding/allocate_servicepoint_minor.php",
body: ["BusinessID": businessId, "ServicePointID": servicePointId],
extraHeaders: ["X-Business-Id": String(businessId)]
)
DebugLog.shared.log("[API] allocateServicePointMinor response: \(json)")
if !parseBool(json["OK"] ?? json["ok"]) {
let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to allocate minor"
throw ApiException(error)
}
guard let minor = parseIntValue(json["MINOR"] ?? json["minor"] ?? json["Minor"] ?? json["BeaconMinor"] ?? json["BEACONMINOR"]) else {
throw ApiException("Invalid response - no Minor value")
}
return UInt16(minor)
}
/// List service points for a business (for beacon assignment)
func listServicePoints(businessId: Int) async throws -> [ServicePoint] {
let json = try await postRequest(

View file

@ -1,10 +1,42 @@
import Foundation
import CoreBluetooth
/// Structured provisioning error codes (matches Android's BeaconConfig error codes)
enum ProvisioningError: String, LocalizedError {
case bluetoothUnavailable = "BLUETOOTH_UNAVAILABLE"
case connectionFailed = "CONNECTION_FAILED"
case connectionTimeout = "CONNECTION_TIMEOUT"
case serviceNotFound = "SERVICE_NOT_FOUND"
case authFailed = "AUTH_FAILED"
case writeFailed = "WRITE_FAILED"
case verificationFailed = "VERIFICATION_FAILED"
case disconnected = "DISCONNECTED"
case noConfig = "NO_CONFIG"
case timeout = "TIMEOUT"
case unknown = "UNKNOWN"
var errorDescription: String? {
switch self {
case .bluetoothUnavailable: return "Bluetooth not available"
case .connectionFailed: return "Failed to connect to beacon"
case .connectionTimeout: return "Connection timed out"
case .serviceNotFound: return "Config service not found on device"
case .authFailed: return "Authentication failed - all passwords rejected"
case .writeFailed: return "Failed to write configuration"
case .verificationFailed: return "Beacon not broadcasting expected values"
case .disconnected: return "Unexpected disconnect"
case .noConfig: return "No configuration provided"
case .timeout: return "Operation timed out"
case .unknown: return "Unknown error"
}
}
}
/// Result of a provisioning operation
enum ProvisioningResult {
case success(macAddress: String?)
case failure(String)
case failureWithCode(ProvisioningError, detail: String? = nil)
}
/// Configuration to write to a beacon
@ -64,8 +96,8 @@ class BeaconProvisioner: NSObject, ObservableObject {
// DX-Smart packet header
private static let DXSMART_HEADER: [UInt8] = [0x4E, 0x4F]
// DX-Smart connection password
private static let DXSMART_PASSWORD = "dx1234"
// DX-Smart connection passwords (tried in order until one works)
private static let DXSMART_PASSWORDS = ["555555", "dx1234", "000000"]
// DX-Smart command codes
private enum DXCmd: UInt8 {
@ -163,7 +195,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
/// Provision a beacon with the given configuration
func provision(beacon: DiscoveredBeacon, config: BeaconConfig, completion: @escaping (ProvisioningResult) -> Void) {
guard centralManager.state == .poweredOn else {
completion(.failure("Bluetooth not available"))
completion(.failureWithCode(.bluetoothUnavailable))
return
}
@ -193,7 +225,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
// Timeout after 30 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in
if self?.state != .success && self?.state != .idle {
self?.fail("Connection timeout")
self?.fail("Connection timeout", code: .connectionTimeout)
}
}
}
@ -221,6 +253,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
self.operationMode = .readingConfig
self.readCompletion = completion
self.readResult = BeaconCheckResult()
self.passwordIndex = 0
self.characteristics.removeAll()
self.dxSmartAuthenticated = false
self.dxSmartNotifySubscribed = false
@ -268,13 +301,17 @@ class BeaconProvisioner: NSObject, ObservableObject {
progress = ""
}
private func fail(_ message: String) {
DebugLog.shared.log("BLE: Failed - \(message)")
private func fail(_ message: String, code: ProvisioningError? = nil) {
DebugLog.shared.log("BLE: Failed [\(code?.rawValue ?? "UNTYPED")] - \(message)")
state = .failed(message)
if let peripheral = peripheral {
centralManager.cancelPeripheralConnection(peripheral)
}
completion?(.failure(message))
if let code = code {
completion?(.failureWithCode(code, detail: message))
} else {
completion?(.failure(message))
}
cleanup()
}
@ -293,7 +330,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
private func provisionDXSmart() {
guard let service = configService else {
fail("DX-Smart config service not found")
fail("DX-Smart config service not found", code: .serviceNotFound)
return
}
@ -319,21 +356,40 @@ class BeaconProvisioner: NSObject, ObservableObject {
}
}
/// Write password to FFE3
/// Write password to FFE3 (tries multiple passwords in sequence)
private func dxSmartAuthenticate() {
guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else {
fail("DX-Smart password characteristic (FFE3) not found")
fail("DX-Smart password characteristic (FFE3) not found", code: .serviceNotFound)
return
}
guard passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count else {
fail("Authentication failed - all passwords rejected", code: .authFailed)
return
}
state = .authenticating
progress = "Authenticating..."
let currentPassword = BeaconProvisioner.DXSMART_PASSWORDS[passwordIndex]
progress = "Authenticating (attempt \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count))..."
let passwordData = Data(BeaconProvisioner.DXSMART_PASSWORD.utf8)
DebugLog.shared.log("BLE: Writing password to FFE3 (\(passwordData.count) bytes)")
let passwordData = Data(currentPassword.utf8)
DebugLog.shared.log("BLE: Writing password \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count) to FFE3 (\(passwordData.count) bytes)")
peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse)
}
/// Called when a password attempt fails tries the next one
private func dxSmartRetryNextPassword() {
passwordIndex += 1
if passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count {
DebugLog.shared.log("BLE: Password rejected, trying next (\(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count))")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.dxSmartAuthenticate()
}
} else {
fail("Authentication failed - all \(BeaconProvisioner.DXSMART_PASSWORDS.count) passwords rejected", code: .authFailed)
}
}
/// Read device info (MAC address) before writing config
private func dxSmartReadDeviceInfoBeforeWrite() {
guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else {
@ -381,7 +437,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
/// 24. SaveConfig 0x60 persist to flash
private func dxSmartWriteConfig() {
guard let config = config else {
fail("No config provided")
fail("No config provided", code: .noConfig)
return
}
@ -479,7 +535,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
progress = "Writing config (\(current)/\(total))..."
guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else {
fail("DX-Smart command characteristic (FFE2) not found")
fail("DX-Smart command characteristic (FFE2) not found", code: .serviceNotFound)
return
}
@ -568,7 +624,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
}
}
/// Authenticate on FFE3 for read mode
/// Authenticate on FFE3 for read mode (uses same multi-password fallback)
private func dxSmartReadAuth() {
guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else {
DebugLog.shared.log("BLE: No FFE3 for auth, finishing")
@ -576,11 +632,18 @@ class BeaconProvisioner: NSObject, ObservableObject {
return
}
state = .authenticating
progress = "Authenticating..."
guard passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count else {
DebugLog.shared.log("BLE: All passwords exhausted in read mode")
finishRead()
return
}
let passwordData = Data(BeaconProvisioner.DXSMART_PASSWORD.utf8)
DebugLog.shared.log("BLE: Read mode — writing password to FFE3")
state = .authenticating
let currentPassword = BeaconProvisioner.DXSMART_PASSWORDS[passwordIndex]
progress = "Authenticating (attempt \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count))..."
let passwordData = Data(currentPassword.utf8)
DebugLog.shared.log("BLE: Read mode — writing password \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count) to FFE3")
peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse)
}
@ -897,7 +960,7 @@ extension BeaconProvisioner: CBCentralManagerDelegate {
if operationMode == .readingConfig {
readFail(msg)
} else {
fail(msg)
fail(msg, code: .connectionFailed)
}
}
}
@ -912,7 +975,7 @@ extension BeaconProvisioner: CBCentralManagerDelegate {
if case .failed = state {
// Already failed
} else {
fail("Unexpected disconnect")
fail("Unexpected disconnect", code: .disconnected)
}
}
}
@ -927,7 +990,7 @@ extension BeaconProvisioner: CBPeripheralDelegate {
if operationMode == .readingConfig {
readFail("Service discovery failed: \(error.localizedDescription)")
} else {
fail("Service discovery failed: \(error.localizedDescription)")
fail("Service discovery failed: \(error.localizedDescription)", code: .serviceNotFound)
}
return
}
@ -936,7 +999,7 @@ extension BeaconProvisioner: CBPeripheralDelegate {
if operationMode == .readingConfig {
readFail("No services found")
} else {
fail("No services found")
fail("No services found", code: .serviceNotFound)
}
return
}
@ -959,7 +1022,7 @@ extension BeaconProvisioner: CBPeripheralDelegate {
return
}
}
fail("Config service not found on device")
fail("Config service not found on device", code: .serviceNotFound)
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
@ -969,7 +1032,7 @@ extension BeaconProvisioner: CBPeripheralDelegate {
DebugLog.shared.log("BLE: Char discovery failed for \(service.uuid): \(error.localizedDescription)")
exploreNextService()
} else {
fail("Characteristic discovery failed: \(error.localizedDescription)")
fail("Characteristic discovery failed: \(error.localizedDescription)", code: .serviceNotFound)
}
return
}
@ -978,7 +1041,7 @@ extension BeaconProvisioner: CBPeripheralDelegate {
if operationMode == .readingConfig {
exploreNextService()
} else {
fail("No characteristics found")
fail("No characteristics found", code: .serviceNotFound)
}
return
}
@ -1015,10 +1078,14 @@ extension BeaconProvisioner: CBPeripheralDelegate {
DebugLog.shared.log("BLE: Write failed for \(characteristic.uuid): \(error.localizedDescription)")
if characteristic.uuid == BeaconProvisioner.DXSMART_PASSWORD_CHAR {
if operationMode == .readingConfig {
readFail("Authentication failed: \(error.localizedDescription)")
// Password rejected try next password in the list
if passwordIndex + 1 < BeaconProvisioner.DXSMART_PASSWORDS.count {
DebugLog.shared.log("BLE: Password \(passwordIndex + 1) rejected, trying next...")
dxSmartRetryNextPassword()
} else if operationMode == .readingConfig {
readFail("Authentication failed - all passwords rejected")
} else {
fail("Authentication failed: \(error.localizedDescription)")
fail("Authentication failed - all \(BeaconProvisioner.DXSMART_PASSWORDS.count) passwords rejected", code: .authFailed)
}
return
}
@ -1041,7 +1108,7 @@ extension BeaconProvisioner: CBPeripheralDelegate {
self?.dxSmartSendNextCommand()
}
} else {
fail("Command write failed at step \(dxSmartWriteIndex + 1): \(error.localizedDescription)")
fail("Command write failed at step \(dxSmartWriteIndex + 1): \(error.localizedDescription)", code: .writeFailed)
}
}
return
@ -1051,7 +1118,7 @@ extension BeaconProvisioner: CBPeripheralDelegate {
DebugLog.shared.log("BLE: Write failed in read mode, ignoring: \(error.localizedDescription)")
return
}
fail("Write failed: \(error.localizedDescription)")
fail("Write failed: \(error.localizedDescription)", code: .writeFailed)
return
}

View file

@ -913,6 +913,10 @@ struct ScanView: View {
}
case .failure(let error):
failProvisioning(error)
case .failureWithCode(let code, let detail):
let msg = detail ?? code.errorDescription ?? code.rawValue
DebugLog.shared.log("[ScanView] Provisioning failed [\(code.rawValue)]: \(msg)")
failProvisioning(msg)
}
}
}
@ -996,6 +1000,10 @@ struct ScanView: View {
}
case .failure(let error):
failProvisioning(error)
case .failureWithCode(let code, let detail):
let msg = detail ?? code.errorDescription ?? code.rawValue
DebugLog.shared.log("[ScanView] Provisioning failed [\(code.rawValue)]: \(msg)")
failProvisioning(msg)
}
}
}