feat(devices): protected device enrollment + mobileconfig generator

Backend:
- ProtectedDevice prisma model + migration add_protected_devices
- DB helpers: list/count/get/create/confirm/revoke
- mobileconfig.ts utility — XML-escape, unique UUIDs per request
- 5 endpoints under /api/devices/* (avoid /api/devices conflict with existing
  Capacitor UserDevice route by using /api/devices/protected for list)

Phase 1: backend ready. DoH-server token-routing comes in phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-11 04:06:49 +02:00
parent 3088526fc1
commit 677b67902b
9 changed files with 504 additions and 0 deletions

View File

@ -0,0 +1,23 @@
-- Migration: add_protected_devices
-- Multi-Device DNS-Schutz für Legend-User.
-- Legend: bis zu 3 Geräte mit individuellem DoH-Token geschützt.
CREATE TABLE "rebreak"."protected_devices" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"user_id" UUID NOT NULL,
"dns_token" TEXT NOT NULL,
"platform" TEXT NOT NULL,
"label" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"installed_at" TIMESTAMPTZ,
"last_dns_query_at" TIMESTAMPTZ,
"revoked_at" TIMESTAMPTZ,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT "protected_devices_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "protected_devices_dns_token_key" ON "rebreak"."protected_devices"("dns_token");
CREATE INDEX "protected_devices_user_id_idx" ON "rebreak"."protected_devices"("user_id");
CREATE INDEX "protected_devices_dns_token_idx" ON "rebreak"."protected_devices"("dns_token");

View File

@ -709,3 +709,32 @@ model UserDevice {
@@map("user_devices")
@@schema("rebreak")
}
// Multi-Device DNS-Schutz: Legend-User können bis zu 3 Geräte (Mac/iOS/Android/Windows)
// mit einem per-Device DoH-Token schützen. Token wird in die mobileconfig/DoH-URL
// eingebettet — ist die einzige Auth für den DoH-Server (Phase 2).
model ProtectedDevice {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
/// Per-Device DoH-Token, embedded in mobileconfig ServerURL.
/// Format: 32-char cryptographic-random hex.
dnsToken String @unique @map("dns_token")
/// "mac" | "windows" | "ios" | "android"
platform String
/// User-friendly label, z.B. "MacBook Pro" oder "Olfas iPhone"
label String
/// pending (enrolled, profile not installed yet) | active (user confirmed install) | revoked
status String @default("pending")
/// User confirmed install via App (not server-side verified yet — DoH-routing kommt in Phase 2)
installedAt DateTime? @map("installed_at")
/// Optional: DoH-server pingt das später (Phase 2, separater Sprint)
lastDnsQueryAt DateTime? @map("last_dns_query_at")
revokedAt DateTime? @map("revoked_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([userId])
@@index([dnsToken])
@@map("protected_devices")
@@schema("rebreak")
}

View File

@ -0,0 +1,26 @@
import { confirmProtectedDeviceInstalled } from "../../../db/protectedDevices";
/**
* POST /api/devices/:id/confirm-installed
*
* User klickt "Installiert" in der App nach dem Profil-Download.
* Setzt installedAt = NOW() und status = "active".
*
* Auth: requireUser + ownership (confirmProtectedDeviceInstalled prüft userId).
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const id = getRouterParam(event, "id");
if (!id) throw createError({ statusCode: 400, data: { error: "ID_REQUIRED" } });
const updated = await confirmProtectedDeviceInstalled(id, user.id);
if (!updated) {
throw createError({
statusCode: 404,
data: { error: "DEVICE_NOT_FOUND_OR_REVOKED" },
});
}
return { success: true, data: { status: updated.status, installedAt: updated.installedAt } };
});

View File

@ -0,0 +1,45 @@
import { getProtectedDevice } from "../../../db/protectedDevices";
import {
generateMacOSDnsProfile,
labelToSlug,
} from "../../../utils/mobileconfig";
/**
* GET /api/devices/:id/profile.mobileconfig
*
* PUBLIC der Mac muss ohne Auth-Header zugreifen können.
* Der dnsToken im Profil IST die Device-Authentifizierung beim DoH-Server.
*
* Liefert ein macOS DNS-Over-HTTPS Konfigurationsprofil.
* Content-Type: application/x-apple-aspen-config
*/
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
if (!id) throw createError({ statusCode: 400, data: { error: "ID_REQUIRED" } });
const device = await getProtectedDevice(id);
if (!device || device.status === "revoked") {
throw createError({ statusCode: 404, data: { error: "DEVICE_NOT_FOUND" } });
}
const plist = generateMacOSDnsProfile({
deviceId: device.id,
dnsToken: device.dnsToken,
label: device.label,
});
const slug = labelToSlug(device.label);
const filename = `rebreak-${slug || "schutz"}.mobileconfig`;
setHeader(event, "Content-Type", "application/x-apple-aspen-config");
setHeader(
event,
"Content-Disposition",
`attachment; filename="${filename}"`,
);
// Kein Caching — Token ist sensitiv
setHeader(event, "Cache-Control", "no-store");
return plist;
});

View File

@ -0,0 +1,36 @@
import { revokeProtectedDevice } from "../../../db/protectedDevices";
/**
* DELETE /api/devices/:id/revoke
*
* User entfernt ein Protected-Device aus der App.
* Soft-delete: status=revoked, revokedAt=NOW(). Kein hard-delete (DSGVO-Audit-Trail).
*
* WICHTIG: Das Profil bleibt auf dem Mac installiert bis der User es manuell
* entfernt. Das Backend kann das nicht aus der Ferne erzwingen (Phase 2: MDM).
*
* Auth: requireUser + ownership (revokeProtectedDevice prüft userId).
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const id = getRouterParam(event, "id");
if (!id) throw createError({ statusCode: 400, data: { error: "ID_REQUIRED" } });
const revoked = await revokeProtectedDevice(id, user.id);
if (!revoked) {
throw createError({
statusCode: 404,
data: { error: "DEVICE_NOT_FOUND_OR_ALREADY_REVOKED" },
});
}
return {
success: true,
manualRemovalRequired: true,
data: {
error: "MANUAL_REMOVAL_REQUIRED",
// Frontend übersetzt diesen Code in die passende UI-Meldung
},
};
});

View File

@ -0,0 +1,76 @@
import { randomBytes } from "crypto";
import { getProfile } from "../../db/profile";
import {
countActiveProtectedDevices,
createProtectedDevice,
} from "../../db/protectedDevices";
/**
* POST /api/devices/enroll
*
* Legend-only. User klickt "Mac hinzufügen" in der App.
* Legt ein ProtectedDevice (status=pending) an und gibt die Download-URL
* für das mobileconfig-Profil zurück.
*
* Body: { platform: "mac" | "windows" | "ios" | "android", label: string }
* Response: { deviceId, dnsToken, downloadUrl }
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const profile = await getProfile(user.id);
if (profile?.plan !== "legend") {
throw createError({
statusCode: 403,
data: { error: "LEGEND_REQUIRED" },
});
}
const body = await readBody(event);
const platform = body?.platform as string | undefined;
const label = body?.label as string | undefined;
const VALID_PLATFORMS = ["mac", "windows", "ios", "android"];
if (!platform || !VALID_PLATFORMS.includes(platform)) {
throw createError({
statusCode: 400,
data: { error: "INVALID_PLATFORM", validValues: VALID_PLATFORMS },
});
}
if (!label || typeof label !== "string" || label.trim().length === 0) {
throw createError({ statusCode: 400, data: { error: "LABEL_REQUIRED" } });
}
const trimmedLabel = label.trim().slice(0, 100);
// Limit: max 3 active+pending Devices
const activeCount = await countActiveProtectedDevices(user.id);
if (activeCount >= 3) {
throw createError({
statusCode: 409,
data: { error: "DEVICE_LIMIT_REACHED", max: 3, current: activeCount },
});
}
// 32-char hex token — kryptografisch sicher
const dnsToken = randomBytes(16).toString("hex");
const device = await createProtectedDevice({
userId: user.id,
dnsToken,
platform,
label: trimmedLabel,
});
const config = useRuntimeConfig(event);
const apiBase =
(config.public as any)?.apiBase ?? "https://api.rebreak.org";
return {
success: true,
data: {
deviceId: device.id,
dnsToken: device.dnsToken,
downloadUrl: `${apiBase}/api/devices/${device.id}/profile.mobileconfig`,
},
};
});

View File

@ -0,0 +1,27 @@
import { getProfile } from "../../db/profile";
import { listProtectedDevices } from "../../db/protectedDevices";
/**
* GET /api/devices/protected
*
* Liste aller aktiven+pending ProtectedDevices des Users.
* Niemals dnsToken zurückgeben Security.
*
* Auth: requireUser
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const profile = await getProfile(user.id);
const devices = await listProtectedDevices(user.id);
return {
success: true,
data: {
devices,
plan: profile?.plan ?? "free",
max: 3,
isLegend: profile?.plan === "legend",
},
};
});

View File

@ -0,0 +1,139 @@
import { usePrisma } from "../utils/prisma";
export interface ProtectedDeviceRecord {
id: string;
platform: string;
label: string;
status: string;
installedAt: Date | null;
createdAt: Date;
}
export interface ProtectedDeviceWithToken extends ProtectedDeviceRecord {
dnsToken: string;
userId: string;
}
/** Alle nicht-revoked Devices eines Users, neueste zuerst. */
export async function listProtectedDevices(
userId: string,
): Promise<ProtectedDeviceRecord[]> {
const db = usePrisma();
return db.protectedDevice.findMany({
where: { userId, status: { not: "revoked" } },
orderBy: { createdAt: "desc" },
select: {
id: true,
platform: true,
label: true,
status: true,
installedAt: true,
createdAt: true,
},
});
}
/** Anzahl der aktiven+pending Devices für Limit-Check. */
export async function countActiveProtectedDevices(
userId: string,
): Promise<number> {
const db = usePrisma();
return db.protectedDevice.count({
where: { userId, status: { in: ["active", "pending"] } },
});
}
/** Lookup by id — inkl. dnsToken und userId (für mobileconfig-Generation + ownership-check). */
export async function getProtectedDevice(
id: string,
): Promise<ProtectedDeviceWithToken | null> {
const db = usePrisma();
return db.protectedDevice.findUnique({
where: { id },
select: {
id: true,
userId: true,
dnsToken: true,
platform: true,
label: true,
status: true,
installedAt: true,
createdAt: true,
},
});
}
/** Anlegen eines neuen ProtectedDevice (status=pending). */
export async function createProtectedDevice(opts: {
userId: string;
dnsToken: string;
platform: string;
label: string;
}): Promise<ProtectedDeviceWithToken> {
const db = usePrisma();
return db.protectedDevice.create({
data: {
userId: opts.userId,
dnsToken: opts.dnsToken,
platform: opts.platform,
label: opts.label,
status: "pending",
},
select: {
id: true,
userId: true,
dnsToken: true,
platform: true,
label: true,
status: true,
installedAt: true,
createdAt: true,
},
});
}
/** User bestätigt Installation — setzt installedAt + status=active. */
export async function confirmProtectedDeviceInstalled(
id: string,
userId: string,
): Promise<ProtectedDeviceRecord | null> {
const db = usePrisma();
const device = await db.protectedDevice.findFirst({
where: { id, userId, status: { not: "revoked" } },
});
if (!device) return null;
return db.protectedDevice.update({
where: { id },
data: {
status: "active",
installedAt: new Date(),
},
select: {
id: true,
platform: true,
label: true,
status: true,
installedAt: true,
createdAt: true,
},
});
}
/** Soft-delete: setzt status=revoked + revokedAt. Ownership-check via userId. */
export async function revokeProtectedDevice(
id: string,
userId: string,
): Promise<boolean> {
const db = usePrisma();
const device = await db.protectedDevice.findFirst({
where: { id, userId, status: { not: "revoked" } },
});
if (!device) return false;
await db.protectedDevice.update({
where: { id },
data: { status: "revoked", revokedAt: new Date() },
});
return true;
}

View File

@ -0,0 +1,103 @@
import { randomUUID } from "crypto";
/** XML-Escape für User-Input in plist-Strings. */
function xmlEscape(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/** Slugify label für Content-Disposition filename. */
export function labelToSlug(label: string): string {
return label
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
}
/**
* Generiert ein macOS DNS-Over-HTTPS Konfigurationsprofil (plist XML).
*
* Jeder Aufruf erzeugt neue random PayloadUUIDs damit der Mac mehrere Profile
* unterscheiden kann falls ein User das Profil neu lädt.
*
* Phase 2: DoH-Server routet per dnsToken auf user-spezifische Blocklist.
*/
export function generateMacOSDnsProfile(opts: {
deviceId: string;
dnsToken: string;
label: string;
}): string {
const { deviceId, dnsToken, label } = opts;
// Unique UUIDs pro Generierung (nicht deviceId verwenden — Mac dedupliciert sonst)
const outerUUID = randomUUID().toUpperCase();
const innerUUID = randomUUID().toUpperCase();
// PayloadIdentifier: stabil pro Device (kein UUID hier — bleibt gleich bei Re-Download)
const tokenPrefix = dnsToken.slice(0, 8);
const payloadId = `org.rebreak.protection.dns.${tokenPrefix}`;
const displayName = xmlEscape(`ReBreak Schutz — ${label}`);
const innerDisplayName = xmlEscape("ReBreak DNS-Filter");
const description = xmlEscape(
"Leitet DNS-Anfragen über dns.rebreak.org. Glücksspiel-Domains werden blockiert.",
);
const outerDescription = xmlEscape(
"Aktiviert den ReBreak-DNS-Filter auf diesem Mac. Glücksspiel-Domains werden auf System-Ebene blockiert — gilt für alle Browser und alle Apps. Entfernen erfordert Admin-Passwort.",
);
const serverURL = `https://dns.rebreak.org/api/dns/${dnsToken}/dns-query`;
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>${innerDisplayName}</string>
<key>PayloadDescription</key>
<string>${description}</string>
<key>PayloadIdentifier</key>
<string>${payloadId}.dns</string>
<key>PayloadType</key>
<string>com.apple.dnsSettings.managed</string>
<key>PayloadUUID</key>
<string>${innerUUID}</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>DNSSettings</key>
<dict>
<key>DNSProtocol</key>
<string>HTTPS</string>
<key>ServerURL</key>
<string>${serverURL}</string>
</dict>
</dict>
</array>
<key>PayloadDisplayName</key>
<string>${displayName}</string>
<key>PayloadDescription</key>
<string>${outerDescription}</string>
<key>PayloadIdentifier</key>
<string>${payloadId}</string>
<key>PayloadOrganization</key>
<string>ReBreak</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>${outerUUID}</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadRemovalDisallowed</key>
<true/>
</dict>
</plist>`;
}