chahinebrini 5fb441817f feat(magic): RE-hardening Quick Wins (ACL, #if DEBUG guards, rate-limit)
Härtung der öffentlich downloadbaren Magic-Apps gegen Reverse Engineering
(Assessment: docs/specs/magic-re-hardening.md):
- Windows: protection.json per ACL auf SYSTEM+Admins (DNS-Token nicht mehr von
  Standard-Usern lesbar) — setup.rs
- Mac: MacProfileInstaller.remove() + Debug-Supervision-Modi/Reset nur noch
  #if DEBUG (kein Removal-/Debug-Pfad im Release-Binary)
- Mac: staging-URL einmal als Konstante statt 4x Literal; interne Infra-Notizen
  aus String-Literalen raus
- Backend: Rate-Limit (10/IP/min) auf /api/magic/pair/redeem

NUR Backend-Teil deployt via Push; Mac/Win brauchen Xcode-/Cargo-Release-Build
(zied) + Smoke-Tests vor Release. MagicAPIClient.swift trägt etwas vorbestehenden
WIP mit (gleiche Magic-Client-Domäne).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 05:19:10 +02:00

412 lines
14 KiB
Swift

import Foundation
/// Response-Modelle für /api/magic/* Endpoints
struct MagicRegistration: Codable {
let deviceId: String
let dnsToken: String
let profileUrl: String
let existing: Bool
}
enum MagicDeviceSource: String, Codable {
case magic
case locked
case protected
}
struct MagicDevice: Codable, Identifiable {
let source: MagicDeviceSource?
let deviceId: String
let hostname: String
let model: String?
let osVersion: String?
let magicEnrolledAt: String
let releaseRequestedAt: String?
let releaseAvailableAt: String?
var id: String { "\(source?.rawValue ?? "magic"):\(deviceId)" }
/// Default zu `.magic` falls Backend (alte Version) das Feld nicht setzt.
var resolvedSource: MagicDeviceSource { source ?? .magic }
var enrolledDate: Date? {
parseISO(magicEnrolledAt)
}
var releaseDate: Date? {
guard let iso = releaseAvailableAt else { return nil }
return parseISO(iso)
}
var isReleasing: Bool {
releaseRequestedAt != nil
}
}
struct MagicReleaseResponse: Codable {
let releaseRequestedAt: String
let releaseAvailableAt: String
var releaseDate: Date? {
parseISO(releaseAvailableAt)
}
}
/// Parses ISO8601 mit + ohne fractional seconds (Backend sendet `.000Z`-Suffix).
private func parseISO(_ s: String) -> Date? {
let f1 = ISO8601DateFormatter()
f1.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = f1.date(from: s) { return d }
let f2 = ISO8601DateFormatter()
f2.formatOptions = [.withInternetDateTime]
return f2.date(from: s)
}
/// User-Profil aus /api/magic/me \u2014 f\u00fcr Hub-Header (Avatar + Nickname).
struct MagicUserProfile: Codable {
let nickname: String?
let avatar: String?
let plan: String?
}
enum MagicError: Error, LocalizedError {
case unauthorized
case limitReached(activeBindings: [MagicDevice])
case networkError(String)
case httpError(Int, String)
case configMissing(String)
case decodingError(String)
var errorDescription: String? {
switch self {
case .unauthorized:
return "Nicht authentifiziert. Bitte neu einloggen."
case .limitReached(let bindings):
return "Device-Limit erreicht (\(bindings.count) Geräte). Bitte zuerst ein Gerät freigeben."
case .networkError(let msg):
return "Netzwerkfehler: \(msg)"
case .httpError(let status, let msg):
return "HTTP \(status): \(msg)"
case .configMissing(let msg):
return "Config fehlt: \(msg)"
case .decodingError(let msg):
return "Response-Parse-Fehler: \(msg)"
}
}
}
/// HTTP-Client für ReBreak Magic Backend API (/api/magic/*).
/// Injiziert automatisch JWT-Auth via AuthService.
@MainActor
final class MagicAPIClient {
static let shared = MagicAPIClient()
// Default-Backend zentral damit der Wert nur einmal im Binary steht.
private static let defaultBackendURL = "https://staging.rebreak.org"
private let authService = AuthService.shared
private init() {}
// MARK: - Config
private struct Config: Codable {
let backendBaseUrl: String?
}
private static let configPath: String = {
let home = FileManager.default.homeDirectoryForCurrentUser.path
return "\(home)/.config/rebreak-magic/config.json"
}()
private var baseURL: String {
get throws {
// Override via env var for testing
if let envUrl = ProcessInfo.processInfo.environment["REBREAK_BACKEND_URL"] {
return envUrl
}
let url = URL(fileURLWithPath: Self.configPath)
guard FileManager.default.fileExists(atPath: Self.configPath) else {
return Self.defaultBackendURL
}
do {
let data = try Data(contentsOf: url)
let config = try JSONDecoder().decode(Config.self, from: data)
return config.backendBaseUrl ?? Self.defaultBackendURL
} catch {
return Self.defaultBackendURL
}
}
}
// MARK: - Register Device
func register(deviceId: String, hostname: String, model: String, osVersion: String) async throws -> MagicRegistration {
let session = try await authService.refreshSessionIfNeeded()
let url = try URL(string: "\(baseURL)/api/magic/register")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
let body: [String: String] = [
"deviceId": deviceId,
"hostname": hostname,
"model": model,
"osVersion": osVersion
]
request.httpBody = try JSONEncoder().encode(body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw MagicError.networkError("Keine HTTP-Response")
}
if httpResponse.statusCode == 401 {
await authService.signOut()
throw MagicError.unauthorized
}
if httpResponse.statusCode == 409 {
// Limit reached
struct LimitError: Codable {
let code: String
let activeBindings: [MagicDevice]
}
struct ErrorResponse: Codable {
let data: LimitError
}
if let errorData = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
throw MagicError.limitReached(activeBindings: errorData.data.activeBindings)
}
throw MagicError.httpError(409, "Device-Limit erreicht")
}
guard httpResponse.statusCode == 200 else {
let body = String(data: data, encoding: .utf8) ?? ""
throw MagicError.httpError(httpResponse.statusCode, body)
}
struct Response: Codable {
let success: Bool
let data: MagicRegistration
}
do {
let response = try JSONDecoder().decode(Response.self, from: data)
return response.data
} catch {
throw MagicError.decodingError(error.localizedDescription)
}
}
// MARK: - List Devices
func listDevices() async throws -> [MagicDevice] {
let session = try await authService.refreshSessionIfNeeded()
let url = try URL(string: "\(baseURL)/api/magic/devices")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw MagicError.networkError("Keine HTTP-Response")
}
if httpResponse.statusCode == 401 {
await authService.signOut()
throw MagicError.unauthorized
}
guard httpResponse.statusCode == 200 else {
let body = String(data: data, encoding: .utf8) ?? ""
throw MagicError.httpError(httpResponse.statusCode, body)
}
struct Response: Codable {
let success: Bool
let data: [MagicDevice]
}
do {
let response = try JSONDecoder().decode(Response.self, from: data)
return response.data
} catch {
throw MagicError.decodingError(error.localizedDescription)
}
}
// MARK: - Request Release
func requestRelease(deviceId: String) async throws -> Date {
let session = try await authService.refreshSessionIfNeeded()
let url = try URL(string: "\(baseURL)/api/magic/devices/\(deviceId)/request-release")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw MagicError.networkError("Keine HTTP-Response")
}
if httpResponse.statusCode == 401 {
await authService.signOut()
throw MagicError.unauthorized
}
guard httpResponse.statusCode == 200 else {
let body = String(data: data, encoding: .utf8) ?? ""
throw MagicError.httpError(httpResponse.statusCode, body)
}
struct Response: Codable {
let success: Bool
let data: MagicReleaseResponse
}
do {
let response = try JSONDecoder().decode(Response.self, from: data)
guard let date = response.data.releaseDate else {
throw MagicError.decodingError("releaseAvailableAt parse failed")
}
return date
} catch {
throw MagicError.decodingError(error.localizedDescription)
}
}
// MARK: - Cancel Release
func cancelRelease(deviceId: String) async throws {
let session = try await authService.refreshSessionIfNeeded()
let url = try URL(string: "\(baseURL)/api/magic/devices/\(deviceId)/cancel-release")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw MagicError.networkError("Keine HTTP-Response")
}
if httpResponse.statusCode == 401 {
await authService.signOut()
throw MagicError.unauthorized
}
guard httpResponse.statusCode == 200 else {
let body = String(data: data, encoding: .utf8) ?? ""
throw MagicError.httpError(httpResponse.statusCode, body)
}
}
// MARK: - User Profile (Hub-Header)
func fetchMe() async throws -> MagicUserProfile {
let session = try await authService.refreshSessionIfNeeded()
let url = try URL(string: "\(baseURL)/api/magic/me")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw MagicError.networkError("Keine HTTP-Response")
}
if httpResponse.statusCode == 401 {
await authService.signOut()
throw MagicError.unauthorized
}
guard httpResponse.statusCode == 200 else {
let body = String(data: data, encoding: .utf8) ?? ""
throw MagicError.httpError(httpResponse.statusCode, body)
}
struct Response: Codable {
let success: Bool
let data: MagicUserProfile
}
do {
return try JSONDecoder().decode(Response.self, from: data).data
} catch {
throw MagicError.decodingError(error.localizedDescription)
}
}
// MARK: - Status (token-based, no auth)
/// Pollt `/api/magic/status?token=` bestätigt serverseitig, dass das
/// Binding aktiv ist (`active=true`). Kein JWT nötig, der Token ist das Secret.
func status(token: String) async throws -> Bool {
let url = try URL(string: "\(baseURL)/api/magic/status?token=\(token)")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
// KEIN JWT Token in Query
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw MagicError.networkError("Keine HTTP-Response")
}
guard httpResponse.statusCode == 200 else {
let body = String(data: data, encoding: .utf8) ?? ""
throw MagicError.httpError(httpResponse.statusCode, body)
}
struct StatusData: Codable { let active: Bool }
struct Response: Codable {
let success: Bool
let data: StatusData
}
do {
return try JSONDecoder().decode(Response.self, from: data).data.active
} catch {
throw MagicError.decodingError(error.localizedDescription)
}
}
// MARK: - Download Profile
func downloadProfile(token: String) async throws -> URL {
let url = try URL(string: "\(baseURL)/api/magic/profile.mobileconfig?token=\(token)")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
// KEIN JWT Token in Query
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw MagicError.networkError("Keine HTTP-Response")
}
guard httpResponse.statusCode == 200 else {
let body = String(data: data, encoding: .utf8) ?? ""
throw MagicError.httpError(httpResponse.statusCode, body)
}
// Save to tmp
let tmpDir = FileManager.default.temporaryDirectory
let profilePath = tmpDir.appendingPathComponent("RebreakMagic-\(UUID().uuidString).mobileconfig")
try data.write(to: profilePath)
return profilePath
}
}