chahinebrini a95e66560d feat(magic): Hard-Lock + Geräte-UX (Push, Realtime, Detail-Sheet, Offline-Removal)
Devices/Magic:
- Offline-Profil-Enroll deaktiviert (410) — Lock-PW würde im Klartext im
  Download landen; stationärer Schutz läuft jetzt nur über Rebreak Magic
- Mac-DNS-Template: ProhibitDisablement (Filter nicht abschaltbar)
- Push "Neues Gerät verbunden" an mobile Geräte bei neuer Bindung
- Realtime auf user_devices → Settings aktualisiert Magic-Bindings live
- Geräte-Detail-Sheet (Tap auf Gerät): Status, verbunden-seit, Schutz-Donut

Hard-Lock (server-gehaltenes Removal-PW, User sieht es nie):
- magic_removal_password generiert/gespeichert + in Profil injiziert (Lazy-Backfill)
- Reveal NUR bei Account-Löschung (user/delete) + Kündigung (stripe webhook),
  per Resend-Mail + in-Response
- Signing config-gated (inaktiv ohne Cert; Lock greift auch unsigniert)

Migrations: user_devices-Realtime-Publication + magic_removal_password-Spalten

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:26:25 +02:00

101 lines
3.3 KiB
TypeScript

import { randomBytes, randomUUID } from "crypto";
import { execFileSync } from "child_process";
import { writeFileSync, mkdtempSync, readFileSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
/**
* Cooldown (Stunden) zwischen Entfern-Antrag und Sichtbarwerden des
* Removal-Passworts. Schützt gegen impulsives Abschalten im Drang-Fenster.
* Bewusst gleich dem Mobile-Device-Lock (24h) gehalten.
*/
export const MAGIC_RELEASE_COOLDOWN_H = 24;
const PW_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // ohne 0/O/1/I/L
const PW_LEN = 10;
/**
* Generiert ein menschenlesbares Removal-Passwort (Offboarding tippt es ab).
* 10 Zeichen, keine mehrdeutigen Glyphen. ~50 Bit Entropie.
*/
export function generateRemovalPassword(): string {
const bytes = randomBytes(PW_LEN);
let out = "";
for (let i = 0; i < PW_LEN; i++) {
out += PW_ALPHABET[bytes[i] % PW_ALPHABET.length];
}
return out;
}
/**
* Baut das com.apple.profileRemovalPassword-Payload-Dict (als XML-String),
* das in die PayloadContent-Array des Mac/Win-Profils injiziert wird.
*/
export function buildRemovalPasswordPayload(
password: string,
deviceIdSlice: string,
): string {
return ` <dict>
<key>PayloadDisplayName</key>
<string>Entfern-Passwort</string>
<key>PayloadIdentifier</key>
<string>org.rebreak.protection.removalpw.${deviceIdSlice}</string>
<key>PayloadType</key>
<string>com.apple.profileRemovalPassword</string>
<key>PayloadUUID</key>
<string>${randomUUID().toUpperCase()}</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>RemovalPassword</key>
<string>${password}</string>
</dict>`;
}
/**
* Signiert ein mobileconfig-Profil per CMS (openssl smime), WENN Signing-Cert
* + Key via runtimeConfig konfiguriert sind. Sonst wird das Profil UNSIGNIERT
* zurückgegeben — der Lock (RemovalPassword + PayloadRemovalDisallowed +
* ProhibitDisablement) greift auch unsigniert; macOS zeigt nur „Nicht
* verifiziert" statt grünem Häkchen.
*
* ⚠️ Inaktiv bis Cert auf der API-Box provisioniert ist (Pfade via Infisical).
* Verifiziertes Signing-Setup existiert bereits auf der mdm-Box (LE-Cert für
* dns.rebreak.org) — die hier erwarteten Pfade müssen dort/da verfügbar sein.
*/
export function signProfileIfConfigured(
profileXml: string,
signing: { certPath?: string; keyPath?: string; chainPath?: string } | undefined,
): Buffer | string {
if (!signing?.certPath || !signing.keyPath) {
return profileXml; // unsigniert — Lock greift trotzdem
}
const dir = mkdtempSync(join(tmpdir(), "rbk-sign-"));
const inPath = join(dir, "in.mobileconfig");
const outPath = join(dir, "out.mobileconfig");
try {
writeFileSync(inPath, profileXml, "utf8");
const args = [
"smime",
"-sign",
"-signer",
signing.certPath,
"-inkey",
signing.keyPath,
"-nodetach",
"-outform",
"der",
"-in",
inPath,
"-out",
outPath,
];
if (signing.chainPath) {
args.push("-certfile", signing.chainPath);
}
execFileSync("openssl", args, { stdio: "pipe" });
return readFileSync(outPath);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}