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>
101 lines
3.3 KiB
TypeScript
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 });
|
|
}
|
|
}
|