From 5b0a4d03d29623493e49d64f4ea1c6096c126fd8 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Wed, 17 Jun 2026 17:18:40 +0200 Subject: [PATCH] feat(magic): identify current device via hardwareId, migrate existing devices --- .../app/composables/useDeviceStatus.ts | 10 +- .../app/composables/useMagicState.ts | 32 +++ .../app/composables/useProtectionStatus.ts | 12 +- .../rebreak-magic/app/composables/useTauri.ts | 18 +- apps/rebreak-magic/app/pages/status.vue | 33 ++- .../src-tauri/src/backend/api.rs | 14 +- apps/rebreak-magic/src-tauri/src/config.rs | 223 ++++++++++++++++++ apps/rebreak-magic/src-tauri/src/lib.rs | 39 ++- .../src-tauri/src/platform/macos.rs | 74 ++++++ .../src-tauri/src/platform/mod.rs | 75 ++++++ .../src-tauri/src/platform/windows.rs | 94 ++++++++ .../migration.sql | 12 + backend/prisma/schema.prisma | 5 + backend/server/api/magic/devices.get.ts | 3 + backend/server/api/magic/register.post.ts | 92 ++++++-- backend/server/db/devices.ts | 3 + 16 files changed, 703 insertions(+), 36 deletions(-) create mode 100644 apps/rebreak-magic/app/composables/useMagicState.ts create mode 100644 apps/rebreak-magic/src-tauri/src/config.rs create mode 100644 apps/rebreak-magic/src-tauri/src/platform/macos.rs create mode 100644 apps/rebreak-magic/src-tauri/src/platform/mod.rs create mode 100644 apps/rebreak-magic/src-tauri/src/platform/windows.rs create mode 100644 backend/prisma/migrations/20250616_add_hardware_id_to_user_devices/migration.sql diff --git a/apps/rebreak-magic/app/composables/useDeviceStatus.ts b/apps/rebreak-magic/app/composables/useDeviceStatus.ts index 0ac7429..0d2fcb7 100644 --- a/apps/rebreak-magic/app/composables/useDeviceStatus.ts +++ b/apps/rebreak-magic/app/composables/useDeviceStatus.ts @@ -36,7 +36,7 @@ function normalizeHostname(value: string): string { function mapToComputedDevice(d: MagicDeviceInfo, isCurrent: boolean): ComputedDevice { return { deviceId: d.deviceId, - name: d.model ?? d.hostname, + name: d.hostname ?? d.model ?? "Unbenanntes Gerät", platform: normalizePlatform(d.model ?? d.hostname), model: d.model, osVersion: d.osVersion, @@ -53,14 +53,16 @@ export function useDeviceStatus( localHostname: Ref, iphone: Ref, currentDeviceId?: Ref, + currentHardwareId?: Ref, ) { function isCurrentDevice(d: MagicDeviceInfo): boolean { + if (currentHardwareId?.value && d.hardwareId) { + return d.hardwareId === currentHardwareId.value; + } if (currentDeviceId?.value) { return d.deviceId === currentDeviceId.value; } - if (!localHostname.value) return false; - const local = normalizeHostname(localHostname.value); - return normalizeHostname(d.hostname) === local; + return false; } const currentBackendDevice = computed(() => { diff --git a/apps/rebreak-magic/app/composables/useMagicState.ts b/apps/rebreak-magic/app/composables/useMagicState.ts new file mode 100644 index 0000000..9801b93 --- /dev/null +++ b/apps/rebreak-magic/app/composables/useMagicState.ts @@ -0,0 +1,32 @@ +import type { MagicDeviceInfo, RegisterDeviceResponse, IphoneDeviceState } from "./useTauri"; + +export interface MagicSessionState { + token?: string; + deviceId: string; + hardwareId?: string; + dnsToken: string; + profileUrl: string; + label?: string; +} + +export function useMagicSession() { + return useState("magic-session", () => null); +} + +export function useMagicDevices() { + return useState("magic-devices", () => []); +} + +export function useCurrentMagicDevice() { + const session = useMagicSession(); + const devices = useMagicDevices(); + + return computed(() => { + if (!session.value) return null; + return devices.value.find((d) => d.deviceId === session.value!.deviceId) ?? null; + }); +} + +export function useIphoneDevice() { + return useState("iphone-device", () => null); +} diff --git a/apps/rebreak-magic/app/composables/useProtectionStatus.ts b/apps/rebreak-magic/app/composables/useProtectionStatus.ts index 35e16a4..1e2aa5d 100644 --- a/apps/rebreak-magic/app/composables/useProtectionStatus.ts +++ b/apps/rebreak-magic/app/composables/useProtectionStatus.ts @@ -43,9 +43,15 @@ export function useProtectionStatus() { return iphone.value.isSupervised && hasEnrollment && hasLock; }); const currentBackendDevice = computed(() => { - if (!localHostname.value) return null; - const local = normalizeHostname(localHostname.value); - return devices.value.find((d) => normalizeHostname(d.hostname) === local) ?? null; + if (!session.value) return null; + const hardwareId = session.value.hardwareId; + const deviceId = session.value.deviceId; + return ( + devices.value.find((d) => { + if (hardwareId && d.hardwareId) return d.hardwareId === hardwareId; + return d.deviceId === deviceId; + }) ?? null + ); }); const desktopProtected = computed(() => !!desktopProtection.value?.active || !!currentBackendDevice.value); const anyBackendDevice = computed(() => devices.value.length > 0); diff --git a/apps/rebreak-magic/app/composables/useTauri.ts b/apps/rebreak-magic/app/composables/useTauri.ts index 543a462..6e103db 100644 --- a/apps/rebreak-magic/app/composables/useTauri.ts +++ b/apps/rebreak-magic/app/composables/useTauri.ts @@ -41,6 +41,7 @@ export interface RegisterDeviceResponse { export interface MagicDeviceInfo { source: string; deviceId: string; + hardwareId: string | null; hostname: string; model: string | null; osVersion: string | null; @@ -131,11 +132,18 @@ export function useTauri() { } async function registerDevice( - deviceId: string, - model: string | undefined, - osVersion: string | undefined, + model?: string, + osVersion?: string, ): Promise { - return await invokeLogged("register_device", { deviceId, model, osVersion }); + return await invokeLogged("register_device", { model, osVersion }); + } + + async function getHardwareId(): Promise { + return await invokeLogged("get_hardware_id"); + } + + async function getDeviceId(): Promise { + return await invokeLogged("get_device_id"); } async function getStoredSession(): Promise { @@ -287,5 +295,7 @@ export function useTauri() { getDesktopProtectionStatus, setDesktopProtectionStatus, getHostname, + getHardwareId, + getDeviceId, }; } diff --git a/apps/rebreak-magic/app/pages/status.vue b/apps/rebreak-magic/app/pages/status.vue index e92fc92..597b73c 100644 --- a/apps/rebreak-magic/app/pages/status.vue +++ b/apps/rebreak-magic/app/pages/status.vue @@ -149,6 +149,9 @@ const { fetchMe, setDesktopProtectionStatus, getPlatform, + getHardwareId, + getDeviceId, + registerDevice, } = useTauri(); const session = useMagicSession(); @@ -156,7 +159,8 @@ const devices = useMagicDevices(); const iphone = useIphoneDevice(); const protection = useProtectionStatus(); -const currentDeviceId = computed(() => session.value?.deviceId ?? null); +const currentDeviceId = ref(null); +const currentHardwareId = ref(null); const profile = ref(null); const loading = ref(false); @@ -172,7 +176,7 @@ const subscriptionInGracePeriod = ref(false); // Share localHostname from protection composable with device status logic. const localHostname = protection.localHostname; const { currentBackendDevice, iosDevices, desktopDevices, iosStars } = - useDeviceStatus(devices, localHostname, iphone, currentDeviceId); + useDeviceStatus(devices, localHostname, iphone, currentDeviceId, currentHardwareId); const selectedDeviceStars = computed(() => { if (!selectedDevice.value || selectedDevice.value.platform !== "ios") return null; @@ -193,8 +197,33 @@ onMounted(async () => { } catch (e) { platformInfo.value = null; } + await initCurrentDevice(); }); +async function initCurrentDevice() { + try { + const hardwareId = await getHardwareId(); + currentHardwareId.value = hardwareId; + + // Ensure the backend knows this hardware ID. + // For existing devices this performs the one-time hardwareId migration. + const response = await registerDevice( + platformInfo.value?.platform, + undefined, + ); + currentDeviceId.value = response.deviceId; + + session.value = { + deviceId: response.deviceId, + hardwareId, + dnsToken: response.dnsToken, + profileUrl: response.profileUrl, + }; + } catch (e: any) { + console.error("Failed to initialize current device:", e); + } +} + async function loadProfile() { try { profile.value = await fetchMe(); diff --git a/apps/rebreak-magic/src-tauri/src/backend/api.rs b/apps/rebreak-magic/src-tauri/src/backend/api.rs index 375f979..3c9c250 100644 --- a/apps/rebreak-magic/src-tauri/src/backend/api.rs +++ b/apps/rebreak-magic/src-tauri/src/backend/api.rs @@ -21,8 +21,10 @@ pub struct RedeemPairingResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RegisterDeviceRequest { - #[serde(rename = "deviceId")] - pub device_id: String, + #[serde(rename = "deviceId", skip_serializing_if = "Option::is_none")] + pub device_id: Option, + #[serde(rename = "hardwareId", skip_serializing_if = "Option::is_none")] + pub hardware_id: Option, pub hostname: String, pub model: Option, #[serde(rename = "osVersion")] @@ -45,6 +47,8 @@ pub struct MagicDeviceInfo { pub source: String, #[serde(rename = "deviceId")] pub device_id: String, + #[serde(rename = "hardwareId")] + pub hardware_id: Option, pub hostname: String, pub model: Option, #[serde(rename = "osVersion")] @@ -159,14 +163,16 @@ impl MagicApiClient { pub async fn register_device( &self, token: &str, - device_id: &str, + device_id: Option<&str>, + hardware_id: Option<&str>, hostname: &str, model: Option<&str>, os_version: Option<&str>, ) -> AppResult { let url = format!("{}/api/magic/register", self.base_url); let body = RegisterDeviceRequest { - device_id: device_id.to_string(), + device_id: device_id.map(|s| s.to_string()), + hardware_id: hardware_id.map(|s| s.to_string()), hostname: hostname.to_string(), model: model.map(|s| s.to_string()), os_version: os_version.map(|s| s.to_string()), diff --git a/apps/rebreak-magic/src-tauri/src/config.rs b/apps/rebreak-magic/src-tauri/src/config.rs new file mode 100644 index 0000000..4cc9eac --- /dev/null +++ b/apps/rebreak-magic/src-tauri/src/config.rs @@ -0,0 +1,223 @@ +#![allow(dead_code)] + +use crate::error::{AppError, AppResult}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + pub backend_base_url: String, + pub mdm_server_url: String, + pub supabase_url: String, + pub supabase_anon_key: String, + /// Stable device identity known to the backend (UserDevice.deviceId). + /// Filled after first successful device registration. + #[serde(skip_serializing_if = "Option::is_none")] + pub device_id: Option, + /// Hardware-bound identity (UserDevice.hardwareId). + #[serde(skip_serializing_if = "Option::is_none")] + pub hardware_id: Option, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + backend_base_url: "https://staging.rebreak.org".to_string(), + mdm_server_url: "https://mdm.rebreak.org".to_string(), + supabase_url: String::new(), + supabase_anon_key: String::new(), + device_id: None, + hardware_id: None, + } + } +} + +const KEYRING_SERVICE: &str = "org.rebreak.magic"; +const KEYRING_ACCOUNT: &str = "magic-session"; +const SESSION_FILE: &str = "session.json"; +const DESKTOP_PROTECTION_FILE: &str = "desktop-protection.json"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MagicSession { + pub access_token: String, + pub session_id: String, + pub user_id: String, + #[serde(with = "chrono::serde::ts_seconds")] + pub created_at: chrono::DateTime, + pub label: Option, +} + +impl MagicSession { + pub fn new(access_token: String, session_id: String, label: Option) -> Self { + Self { + access_token, + session_id, + user_id: String::new(), + created_at: chrono::Utc::now(), + label, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BinderConfig { + #[serde(rename = "mdmServer")] + pub mdm_server: String, + #[serde(rename = "mdmUser")] + pub mdm_user: String, + #[serde(rename = "mdmApiKey")] + pub mdm_api_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DesktopProtectionState { + pub active: bool, + pub platform: String, + #[serde(with = "chrono::serde::ts_seconds")] + pub activated_at: chrono::DateTime, +} + +impl AppConfig { + pub fn config_dir() -> AppResult { + let dir = dirs::config_dir() + .ok_or_else(|| AppError::new("Could not find config directory"))? + .join("org.rebreak.magic"); + std::fs::create_dir_all(&dir)?; + Ok(dir) + } + + pub fn config_path() -> AppResult { + Ok(Self::config_dir()?.join("config.json")) + } + + pub fn binder_config_path() -> AppResult { + Ok(dirs::config_dir() + .ok_or_else(|| AppError::new("Could not find config directory"))? + .join("rebreak-binder") + .join("config.json")) + } + + pub fn load_binder_config() -> AppResult { + let path = Self::binder_config_path()?; + if !path.exists() { + return Err(AppError::new( + "rebreak-binder config nicht gefunden. Bitte README → 'Config (lokal)'.".to_string(), + )); + } + let json = std::fs::read_to_string(&path)?; + serde_json::from_str(&json) + .map_err(|e| AppError::new(format!("rebreak-binder config kaputt: {}", e))) + } + + pub fn load() -> Self { + match Self::config_path() { + Ok(path) if path.exists() => { + std::fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } + _ => Self::default(), + } + } + + pub fn save(&self) -> AppResult<()> { + let path = Self::config_path()?; + let contents = serde_json::to_string_pretty(self)?; + std::fs::write(path, contents)?; + Ok(()) + } + + fn session_path() -> AppResult { + Ok(Self::config_dir()?.join(SESSION_FILE)) + } + + pub fn save_magic_session(session: &MagicSession) -> AppResult<()> { + let path = Self::session_path()?; + let json = serde_json::to_string_pretty(session) + .map_err(|e| AppError::new(format!("Failed to serialize session: {}", e)))?; + std::fs::write(&path, json)?; + Ok(()) + } + + pub fn load_magic_session() -> AppResult> { + let path = match Self::session_path() { + Ok(p) => p, + Err(_) => return Ok(None), + }; + + if !path.exists() { + return Ok(None); + } + + let json = std::fs::read_to_string(&path)?; + if json.trim().is_empty() { + return Ok(None); + } + + let session = serde_json::from_str(&json) + .map_err(|e| AppError::new(format!("Failed to parse session: {}", e)))?; + Ok(Some(session)) + } + + pub fn clear_magic_session() -> AppResult<()> { + let path = match Self::session_path() { + Ok(p) => p, + Err(_) => return Ok(()), + }; + if path.exists() { + std::fs::remove_file(&path)?; + } + Ok(()) + } + + fn desktop_protection_path() -> AppResult { + Ok(Self::config_dir()?.join(DESKTOP_PROTECTION_FILE)) + } + + pub fn save_desktop_protection(state: &DesktopProtectionState) -> AppResult<()> { + let path = Self::desktop_protection_path()?; + let json = serde_json::to_string_pretty(state) + .map_err(|e| AppError::new(format!("Failed to serialize desktop protection state: {}", e)))?; + std::fs::write(&path, json)?; + Ok(()) + } + + pub fn load_desktop_protection() -> AppResult> { + let path = match Self::desktop_protection_path() { + Ok(p) => p, + Err(_) => return Ok(None), + }; + if !path.exists() { + return Ok(None); + } + let json = std::fs::read_to_string(&path)?; + if json.trim().is_empty() { + return Ok(None); + } + let state = serde_json::from_str(&json) + .map_err(|e| AppError::new(format!("Failed to parse desktop protection state: {}", e)))?; + Ok(Some(state)) + } + + pub fn clear_desktop_protection() -> AppResult<()> { + let path = match Self::desktop_protection_path() { + Ok(p) => p, + Err(_) => return Ok(()), + }; + if path.exists() { + std::fs::remove_file(&path)?; + } + Ok(()) + } + + pub fn set_device_id(&mut self, id: String) -> AppResult<()> { + self.device_id = Some(id); + self.save() + } + + pub fn set_hardware_id(&mut self, id: String) -> AppResult<()> { + self.hardware_id = Some(id); + self.save() + } +} diff --git a/apps/rebreak-magic/src-tauri/src/lib.rs b/apps/rebreak-magic/src-tauri/src/lib.rs index 2da0291..1c09a4e 100644 --- a/apps/rebreak-magic/src-tauri/src/lib.rs +++ b/apps/rebreak-magic/src-tauri/src/lib.rs @@ -54,6 +54,8 @@ pub fn run() { get_desktop_protection_status, set_desktop_protection_status, get_hostname, + get_hardware_id, + get_device_id, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); @@ -71,6 +73,26 @@ fn hostname() -> AppResult { Ok(hostname) } +fn get_or_init_hardware_id(config: &mut AppConfig) -> AppResult { + if let Some(id) = config.hardware_id.clone() { + return Ok(id); + } + let id = platform::get_hardware_id()?; + config.set_hardware_id(id.clone())?; + Ok(id) +} + +#[tauri::command] +fn get_hardware_id() -> AppResult { + let mut config = AppConfig::load(); + get_or_init_hardware_id(&mut config) +} + +#[tauri::command] +fn get_device_id() -> AppResult> { + Ok(AppConfig::load().device_id) +} + fn require_session() -> AppResult { AppConfig::load_magic_session()?.ok_or_else(|| { error::AppError::new("Keine Magic-Session gefunden. Bitte neu paaren.") @@ -99,17 +121,28 @@ async fn redeem_pairing_code(code: String, label: Option) -> AppResult, os_version: Option, ) -> AppResult { let session = require_session()?; - let config = AppConfig::load(); + let mut config = AppConfig::load(); let client = MagicApiClient::new(&config); let hostname = hostname()?; + + // Hardware-ID ist der stabile Identifikator; deviceId wird vom Backend zurückgeliefert. + let hardware_id = get_or_init_hardware_id(&mut config)?; let response = client - .register_device(&session.access_token, &device_id, &hostname, model.as_deref(), os_version.as_deref()) + .register_device( + &session.access_token, + config.device_id.as_deref(), + Some(&hardware_id), + &hostname, + model.as_deref(), + os_version.as_deref(), + ) .await?; + + config.set_device_id(response.device_id.clone())?; Ok(response) } diff --git a/apps/rebreak-magic/src-tauri/src/platform/macos.rs b/apps/rebreak-magic/src-tauri/src/platform/macos.rs new file mode 100644 index 0000000..6503755 --- /dev/null +++ b/apps/rebreak-magic/src-tauri/src/platform/macos.rs @@ -0,0 +1,74 @@ +#![allow(dead_code)] + +use super::{Platform, PlatformInfo}; +use crate::error::AppResult; + +pub fn get_platform_info() -> AppResult { + Ok(PlatformInfo { + platform: Platform::MacOS, + version: "14.0".to_string(), // TODO: Read actual macOS version + supports_ios_supervision: true, + }) +} + +pub fn install_dns_profile(profile_path: &str) -> AppResult<()> { + use std::process::Command; + let status = Command::new("open") + .arg(profile_path) + .status() + .map_err(|e| crate::error::AppError::new(format!("Failed to open profile: {}", e)))?; + + if !status.success() { + return Err(crate::error::AppError::new( + "System Settings konnte das Profil nicht öffnen".to_string(), + )); + } + Ok(()) +} + +pub fn store_token(_token: &str) -> AppResult<()> { + // TODO: Implement using macOS Keychain via keyring crate + Ok(()) +} + +pub fn read_token() -> AppResult> { + // TODO: Implement using macOS Keychain via keyring crate + Ok(None) +} + +pub fn activate_protection(profile_path: &str) -> AppResult<()> { + install_dns_profile(profile_path) +} + +pub fn deactivate_protection(_token: &str) -> AppResult<()> { + Ok(()) +} + +/// Returns the macOS system UUID (IOPlatformUUID). +pub fn get_hardware_id() -> AppResult { + use std::process::Command; + let output = Command::new("ioreg") + .args([ + "-rd1", + "-c", + "IOPlatformExpertDevice", + "-k", + "IOPlatformUUID", + ]) + .output() + .map_err(|e| crate::error::AppError::new(format!("Failed to read hardware ID: {}", e)))?; + + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + if line.contains("IOPlatformUUID") { + let parts: Vec<&str> = line.split('"').collect(); + if let Some(uuid) = parts.iter().rev().find(|s| s.contains('-')) { + return Ok(uuid.trim().to_string()); + } + } + } + + Err(crate::error::AppError::new( + "Could not parse macOS hardware UUID".to_string(), + )) +} diff --git a/apps/rebreak-magic/src-tauri/src/platform/mod.rs b/apps/rebreak-magic/src-tauri/src/platform/mod.rs new file mode 100644 index 0000000..81d82f9 --- /dev/null +++ b/apps/rebreak-magic/src-tauri/src/platform/mod.rs @@ -0,0 +1,75 @@ +#![allow(dead_code)] + +use crate::error::AppResult; +use serde::{Deserialize, Serialize}; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Platform { + MacOS, + Windows, + Linux, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlatformInfo { + pub platform: Platform, + pub version: String, + pub supports_ios_supervision: bool, +} + +#[tauri::command] +pub fn get_platform() -> AppResult { + #[cfg(target_os = "macos")] + return macos::get_platform_info(); + + #[cfg(target_os = "windows")] + return windows::get_platform_info(); + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + Ok(PlatformInfo { + platform: Platform::Unknown, + version: String::new(), + supports_ios_supervision: false, + }) +} + +pub trait PlatformInterface { + fn install_dns_profile(&self, profile_path: &str) -> AppResult<()>; + fn store_token(&self, token: &str) -> AppResult<()>; + fn read_token(&self) -> AppResult>; + fn activate_protection(&self, token: &str) -> AppResult<()>; + fn deactivate_protection(&self, token: &str) -> AppResult<()>; +} + +pub fn activate_protection(profile_or_token: &str) -> AppResult<()> { + #[cfg(target_os = "macos")] + return macos::activate_protection(profile_or_token); + + #[cfg(target_os = "windows")] + return windows::activate_protection(profile_or_token); + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + Err(crate::error::AppError::new( + "Plattform wird nicht unterstützt".to_string(), + )) +} + +pub fn get_hardware_id() -> AppResult { + #[cfg(target_os = "macos")] + return macos::get_hardware_id(); + + #[cfg(target_os = "windows")] + return windows::get_hardware_id(); + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + Err(crate::error::AppError::new( + "Hardware-ID wird auf dieser Plattform nicht unterstützt".to_string(), + )) +} diff --git a/apps/rebreak-magic/src-tauri/src/platform/windows.rs b/apps/rebreak-magic/src-tauri/src/platform/windows.rs new file mode 100644 index 0000000..13f5207 --- /dev/null +++ b/apps/rebreak-magic/src-tauri/src/platform/windows.rs @@ -0,0 +1,94 @@ +#![allow(dead_code)] + +use super::{Platform, PlatformInfo}; +use crate::error::AppResult; + +pub fn get_platform_info() -> AppResult { + Ok(PlatformInfo { + platform: Platform::Windows, + version: "11".to_string(), // TODO: Read actual Windows version + supports_ios_supervision: true, // Once supervise-magic Windows build is verified + }) +} + +pub fn install_doh(dns_token: &str) -> AppResult<()> { + use std::process::Command; + + let doh_url = format!("https://dns.rebreak.org/dns-query/{}", dns_token); + let script = format!( + r#" +$iface = Get-NetAdapter | Where-Object {{ $_.Status -eq 'Up' }} | Select-Object -First 1 +if (-not $iface) {{ exit 1 }} +Set-DnsClientDohServerAddress -ServerAddress '142.132.245.42' -DohTemplate '{}' -AutoUpgrade $true +Set-DnsClientServerAddress -InterfaceAlias $iface.Name -ServerAddresses ('142.132.245.42') +"#, + doh_url + ); + + let status = Command::new("powershell.exe") + .args([ + "-NonInteractive", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + &script, + ]) + .status() + .map_err(|e| crate::error::AppError::new(format!("Failed to run PowerShell: {}", e)))?; + + if !status.success() { + return Err(crate::error::AppError::new( + "DoH-Konfiguration fehlgeschlagen. Bitte ReBreak Magic mit Administratorrechten starten.".to_string(), + )); + } + Ok(()) +} + +pub fn store_token(token: &str) -> AppResult<()> { + // TODO: Implement using Windows Credential Manager via keyring crate + Ok(()) +} + +pub fn read_token() -> AppResult> { + // TODO: Implement using Windows Credential Manager via keyring crate + Ok(None) +} + +pub fn activate_protection(token: &str) -> AppResult<()> { + install_doh(token) +} + +pub fn deactivate_protection(_token: &str) -> AppResult<()> { + Ok(()) +} + +/// Returns the Windows machine GUID (Registry: HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid). +pub fn get_hardware_id() -> AppResult { + use std::process::Command; + let output = Command::new("reg") + .args([ + "query", + "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography", + "/v", + "MachineGuid", + ]) + .output() + .map_err(|e| crate::error::AppError::new(format!("Failed to read hardware ID: {}", e)))?; + + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + if line.contains("MachineGuid") { + let parts: Vec<&str> = line.split_whitespace().collect(); + if let Some(uuid) = parts.last() { + if uuid.contains('-') { + return Ok(uuid.to_string()); + } + } + } + } + + Err(crate::error::AppError::new( + "Could not parse Windows machine GUID".to_string(), + )) +} diff --git a/backend/prisma/migrations/20250616_add_hardware_id_to_user_devices/migration.sql b/backend/prisma/migrations/20250616_add_hardware_id_to_user_devices/migration.sql new file mode 100644 index 0000000..fa9ef99 --- /dev/null +++ b/backend/prisma/migrations/20250616_add_hardware_id_to_user_devices/migration.sql @@ -0,0 +1,12 @@ +-- Hardware-ID für UserDevices +-- Client liefert hardwaregebundene ID; Backend speichert sie separat zur bestehenden deviceId. + +ALTER TABLE "rebreak"."user_devices" + ADD COLUMN IF NOT EXISTS "hardware_id" TEXT; + +-- Eindeutigkeit pro User + Hardware-ID (NULL-Werte sind bei UNIQUE erlaubt) +CREATE UNIQUE INDEX IF NOT EXISTS "user_devices_user_id_hardware_id_key" + ON "rebreak"."user_devices"("user_id", "hardware_id"); + +CREATE INDEX IF NOT EXISTS "user_devices_hardware_id_idx" + ON "rebreak"."user_devices"("hardware_id"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 41ee24c..6b66612 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1102,6 +1102,9 @@ model UserDevice { /// Wird in com.apple.profileRemovalPassword injiziert — User sieht es NIE, /// nur nach Cooldown-Release (Offboarding). NULL → noch keins generiert. magicRemovalPassword String? @map("magic_removal_password") + /// Hardware-gebundene ID (z. B. System-UUID, ANDROID_ID, IDFV). + /// Wird vom Client geliefert, nicht vom Backend generiert. + hardwareId String? @map("hardware_id") /// Wann der User die Entfernung des Magic-Profils beantragt hat. /// Removal-Passwort wird erst nach +MAGIC_RELEASE_COOLDOWN_H sichtbar. magicReleaseRequestedAt DateTime? @map("magic_release_requested_at") @@ -1109,8 +1112,10 @@ model UserDevice { magicCooldownUntil DateTime? @map("magic_cooldown_until") @@unique([userId, deviceId]) + @@unique([userId, hardwareId]) @@index([userId]) @@index([deviceId]) + @@index([hardwareId]) @@map("user_devices") @@schema("rebreak") } diff --git a/backend/server/api/magic/devices.get.ts b/backend/server/api/magic/devices.get.ts index 7c78c91..6362da6 100644 --- a/backend/server/api/magic/devices.get.ts +++ b/backend/server/api/magic/devices.get.ts @@ -32,6 +32,7 @@ export default defineEventHandler(async (event) => { select: { id: true, deviceId: true, + hardwareId: true, platform: true, model: true, name: true, @@ -64,6 +65,7 @@ export default defineEventHandler(async (event) => { return { source: "magic" as const, deviceId: d.deviceId, + hardwareId: d.hardwareId, hostname: d.hostname ?? "Unbenanntes Ger\u00e4t", model: d.model, osVersion: d.osVersion, @@ -86,6 +88,7 @@ export default defineEventHandler(async (event) => { return { source: "locked" as const, deviceId: d.deviceId, + hardwareId: d.hardwareId, hostname: d.name ?? d.model ?? prettyPlatform(d.platform), model: d.model, osVersion: d.osVersion, diff --git a/backend/server/api/magic/register.post.ts b/backend/server/api/magic/register.post.ts index afb2e0a..7444d4c 100644 --- a/backend/server/api/magic/register.post.ts +++ b/backend/server/api/magic/register.post.ts @@ -22,21 +22,33 @@ import { generateRemovalPassword } from "../../utils/magic-lock"; export default defineEventHandler(async (event) => { const user = await requireUser(event); const body = await readBody(event); - const { deviceId, hostname, model, osVersion, platform } = body as { + const { deviceId, hardwareId, hostname, model, osVersion, platform } = body as { deviceId?: string; + hardwareId?: string; hostname?: string; model?: string; osVersion?: string; platform?: string; }; - if (!deviceId || !hostname) { + if (!hostname) { throw createError({ statusCode: 400, - message: "deviceId und hostname required", + message: "hostname required", }); } + if (!deviceId && !hardwareId) { + throw createError({ + statusCode: 400, + message: "deviceId oder hardwareId required", + }); + } + + // Für neue Magic-Registrierungen: hardwareId wird gleichzeitig deviceId, + // damit das Backend keine eigene ID generieren muss. + const effectiveDeviceId = deviceId?.trim() || hardwareId!.trim(); + // Plattform: Mac-App sendet nichts (legacy default), Windows-App sendet "windows" const devicePlatform = platform === "windows" ? "windows" : "macos"; @@ -44,17 +56,60 @@ export default defineEventHandler(async (event) => { const db = usePrisma(); // 1. Prüfe ob Device bereits als Magic-Client gebunden ist (idempotent) - const existing = await db.userDevice.findUnique({ - where: { userId_deviceId: { userId: user.id, deviceId } }, - select: { - id: true, - userId: true, - magicDnsToken: true, - magicEnrolledAt: true, - magicRevokedAt: true, - magicRemovalPassword: true, - }, - }); + // Priorität: hardwareId → deviceId → Migration über Modell/Plattform/OS. + let existing = null; + + if (hardwareId) { + existing = await db.userDevice.findFirst({ + where: { userId: user.id, hardwareId }, + select: { + id: true, + userId: true, + deviceId: true, + magicDnsToken: true, + magicEnrolledAt: true, + magicRevokedAt: true, + magicRemovalPassword: true, + }, + }); + } + + if (!existing && deviceId) { + existing = await db.userDevice.findUnique({ + where: { userId_deviceId: { userId: user.id, deviceId } }, + select: { + id: true, + userId: true, + deviceId: true, + magicDnsToken: true, + magicEnrolledAt: true, + magicRevokedAt: true, + magicRemovalPassword: true, + }, + }); + } + + // Migration: bestehendes Gerät ohne hardwareId anhand von Modell/Plattform/OS finden. + if (!existing && hardwareId) { + existing = await db.userDevice.findFirst({ + where: { + userId: user.id, + hardwareId: null, + platform: devicePlatform, + model: model ?? null, + osVersion: osVersion ?? null, + }, + select: { + id: true, + userId: true, + deviceId: true, + magicDnsToken: true, + magicEnrolledAt: true, + magicRevokedAt: true, + magicRemovalPassword: true, + }, + }); + } // Wenn Token existiert und nicht revoked → return existing if ( @@ -132,21 +187,26 @@ export default defineEventHandler(async (event) => { } // 5. Upsert UserDevice (platform="macos" | "windows") + // Bei Migration behalten wir die bestehende deviceId bei. + const upsertDeviceId = existing?.deviceId || effectiveDeviceId; + const device = await db.userDevice.upsert({ - where: { userId_deviceId: { userId: user.id, deviceId } }, + where: { userId_deviceId: { userId: user.id, deviceId: upsertDeviceId } }, create: { userId: user.id, - deviceId, + deviceId: upsertDeviceId, platform: devicePlatform, model: model ?? null, name: hostname, osVersion: osVersion ?? null, + hardwareId: hardwareId ?? null, magicDnsToken: dnsToken, magicEnrolledAt: new Date(), magicHostname: hostname, magicRemovalPassword: removalPassword, }, update: { + hardwareId: hardwareId ?? undefined, magicDnsToken: dnsToken, magicEnrolledAt: new Date(), magicRevokedAt: null, // Clear falls vorher revoked diff --git a/backend/server/db/devices.ts b/backend/server/db/devices.ts index 78e5d19..39ab094 100644 --- a/backend/server/db/devices.ts +++ b/backend/server/db/devices.ts @@ -418,6 +418,7 @@ export async function deleteUserDevice( export interface MagicDeviceRecord { deviceId: string; + hardwareId: string | null; hostname: string | null; model: string | null; osVersion: string | null; @@ -445,6 +446,7 @@ export async function listMagicDevices( orderBy: { magicEnrolledAt: "desc" }, select: { deviceId: true, + hardwareId: true, magicHostname: true, model: true, osVersion: true, @@ -458,6 +460,7 @@ export async function listMagicDevices( return devices.map((d) => ({ deviceId: d.deviceId, + hardwareId: d.hardwareId, hostname: d.magicHostname, model: d.model, osVersion: d.osVersion,