feat(devices): Windows 11 DoH protection — reg-file endpoint + tests
- Add server/utils/regfile.ts: generateWindowsDohRegFile() producing
UTF-16 LE + BOM .reg content for DohWellKnownServers registry path.
label and dohTemplate values are properly escape'd (\, ", \n, \r, \t).
- Add GET /api/devices/:id/profile.reg — public, windows-platform-gated,
returns octet-stream with Content-Disposition attachment.
- Update enroll.post.ts: downloadUrl is now platform-aware
(windows → .reg, all others → .mobileconfig).
- Add tests/devices/regfile.test.ts: 13 tests covering BOM, CRLF,
token embed, subkey naming, AutoUpgradeFlag, label escaping (", \, \n),
and labelToSlug edge cases. All 111 tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
58287f206d
commit
6962e09403
48
backend/server/api/devices/[id]/profile.reg.get.ts
Normal file
48
backend/server/api/devices/[id]/profile.reg.get.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { getProtectedDevice } from "../../../db/protectedDevices";
|
||||
import { labelToSlug } from "../../../utils/mobileconfig";
|
||||
import { generateWindowsDohRegFile } from "../../../utils/regfile";
|
||||
|
||||
/**
|
||||
* GET /api/devices/:id/profile.reg
|
||||
*
|
||||
* PUBLIC — Windows muss ohne Auth-Header zugreifen können.
|
||||
* Der dnsToken im Profil IST die Device-Authentifizierung beim DoH-Server.
|
||||
*
|
||||
* Liefert ein Windows-Registry-File (.reg) das DoH für den ReBreak-DNS-Server
|
||||
* in Windows 11 einrichtet.
|
||||
*
|
||||
* Content-Type: application/octet-stream
|
||||
* Encoding: UTF-16 LE mit BOM (required by regedit.exe)
|
||||
*/
|
||||
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" } });
|
||||
}
|
||||
|
||||
if (device.platform !== "windows") {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
data: { error: "WRONG_PLATFORM", expected: "windows", actual: device.platform },
|
||||
});
|
||||
}
|
||||
|
||||
const regBuffer = generateWindowsDohRegFile({
|
||||
deviceId: device.id,
|
||||
dnsToken: device.dnsToken,
|
||||
label: device.label,
|
||||
});
|
||||
|
||||
const slug = labelToSlug(device.label);
|
||||
const filename = `rebreak-${slug || "schutz"}.reg`;
|
||||
|
||||
setHeader(event, "Content-Type", "application/octet-stream");
|
||||
setHeader(event, "Content-Disposition", `attachment; filename="${filename}"`);
|
||||
setHeader(event, "Cache-Control", "no-store");
|
||||
|
||||
return regBuffer;
|
||||
});
|
||||
@ -65,12 +65,16 @@ export default defineEventHandler(async (event) => {
|
||||
const apiBase =
|
||||
(config.public as any)?.apiBase ?? "https://api.rebreak.org";
|
||||
|
||||
// Platform-aware download URL: Windows gets .reg, everything else .mobileconfig
|
||||
const profileExt = platform === "windows" ? "reg" : "mobileconfig";
|
||||
const downloadUrl = `${apiBase}/api/devices/${device.id}/profile.${profileExt}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
deviceId: device.id,
|
||||
dnsToken: device.dnsToken,
|
||||
downloadUrl: `${apiBase}/api/devices/${device.id}/profile.mobileconfig`,
|
||||
downloadUrl,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
83
backend/server/utils/regfile.ts
Normal file
83
backend/server/utils/regfile.ts
Normal file
@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Windows Registry file generation for DoH (DNS-over-HTTPS) protection setup.
|
||||
*
|
||||
* Windows 11 native DoH client configuration via registry:
|
||||
* - HKLM\SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DohWellKnownServers
|
||||
* - Subkey per DoH server with DohTemplate + AutoUpgradeFlag
|
||||
*
|
||||
* Registry file encoding: UTF-16 LE with BOM (\xFF\xFE).
|
||||
* Line endings: CRLF (\r\n) — required by regedit.exe.
|
||||
*
|
||||
* Escape rules for .reg string values:
|
||||
* \ → \\
|
||||
* " → \"
|
||||
* (newlines, tabs in string values would also need escaping but label is
|
||||
* limited to printable user input, so \n/\t coverage is defensive.)
|
||||
*/
|
||||
|
||||
/** Escape a string for use inside a .reg double-quoted value. */
|
||||
function regEscape(str: string): string {
|
||||
return str
|
||||
.replace(/\\/g, "\\\\") // must be first
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\r/g, "\\r")
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/\t/g, "\\t");
|
||||
}
|
||||
|
||||
export interface WindowsDohRegOpts {
|
||||
/** ProtectedDevice.id — not used in file body but available for future use. */
|
||||
deviceId: string;
|
||||
/** 32-char hex DNS token — used in DoH URL. */
|
||||
dnsToken: string;
|
||||
/** User-set device label, e.g. "Büro-PC". */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the text content of a Windows .reg file that registers a
|
||||
* ReBreak DoH server in the Windows 11 DoH well-known-servers list.
|
||||
*
|
||||
* Returns a UTF-16 LE Buffer with BOM — ready to write as .reg file or send
|
||||
* as HTTP response body. Windows regedit.exe requires this encoding.
|
||||
*/
|
||||
export function generateWindowsDohRegFile(
|
||||
opts: WindowsDohRegOpts,
|
||||
): Buffer {
|
||||
const { dnsToken, label } = opts;
|
||||
|
||||
const tokenPrefix = dnsToken.slice(0, 8);
|
||||
const subkeyName = `rebreak-${tokenPrefix}`;
|
||||
const escapedLabel = regEscape(label);
|
||||
|
||||
// RFC 8484 URI template — {?dns} is the query parameter for GET requests.
|
||||
// Windows DoH client replaces {?dns} with ?dns=<base64url-encoded-query>.
|
||||
const dohTemplate = `https://dns.rebreak.org/api/dns/${dnsToken}/dns-query{?dns}`;
|
||||
|
||||
const isoDate = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const CRLF = "\r\n";
|
||||
|
||||
const lines = [
|
||||
"Windows Registry Editor Version 5.00",
|
||||
"",
|
||||
`; ReBreak DNS-over-HTTPS Filter — Device: ${escapedLabel}`,
|
||||
`; Token: ${tokenPrefix}`,
|
||||
`; Generated: ${isoDate}`,
|
||||
"",
|
||||
"[HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Services\\Dnscache\\Parameters\\DohWellKnownServers]",
|
||||
"",
|
||||
`[HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Services\\Dnscache\\Parameters\\DohWellKnownServers\\${subkeyName}]`,
|
||||
`"DohTemplate"="${regEscape(dohTemplate)}"`,
|
||||
'"AutoUpgradeFlag"=dword:00000001',
|
||||
"",
|
||||
];
|
||||
|
||||
const text = lines.join(CRLF);
|
||||
|
||||
// UTF-16 LE BOM: 0xFF 0xFE
|
||||
const bom = Buffer.from([0xff, 0xfe]);
|
||||
const body = Buffer.from(text, "utf16le");
|
||||
|
||||
return Buffer.concat([bom, body]);
|
||||
}
|
||||
146
backend/tests/devices/regfile.test.ts
Normal file
146
backend/tests/devices/regfile.test.ts
Normal file
@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Tests for generateWindowsDohRegFile + labelToSlug.
|
||||
*
|
||||
* Pure utility — no Prisma, no Nitro globals needed.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateWindowsDohRegFile } from "../../server/utils/regfile";
|
||||
import { labelToSlug } from "../../server/utils/mobileconfig";
|
||||
|
||||
const SAMPLE_TOKEN = "abcdef0123456789abcdef0123456789"; // 32 hex chars
|
||||
const SAMPLE_DEVICE_ID = "device-uuid-1234";
|
||||
|
||||
describe("generateWindowsDohRegFile", () => {
|
||||
it("output starts with UTF-16 LE BOM (0xFF 0xFE)", () => {
|
||||
const buf = generateWindowsDohRegFile({
|
||||
deviceId: SAMPLE_DEVICE_ID,
|
||||
dnsToken: SAMPLE_TOKEN,
|
||||
label: "Test PC",
|
||||
});
|
||||
expect(buf[0]).toBe(0xff);
|
||||
expect(buf[1]).toBe(0xfe);
|
||||
});
|
||||
|
||||
it("decoded content begins with registry header", () => {
|
||||
const buf = generateWindowsDohRegFile({
|
||||
deviceId: SAMPLE_DEVICE_ID,
|
||||
dnsToken: SAMPLE_TOKEN,
|
||||
label: "Test PC",
|
||||
});
|
||||
// Skip 2-byte BOM, decode remainder as UTF-16 LE
|
||||
const text = buf.slice(2).toString("utf16le");
|
||||
expect(text.startsWith("Windows Registry Editor Version 5.00")).toBe(true);
|
||||
});
|
||||
|
||||
it("contains the full dnsToken in the DohTemplate value", () => {
|
||||
const buf = generateWindowsDohRegFile({
|
||||
deviceId: SAMPLE_DEVICE_ID,
|
||||
dnsToken: SAMPLE_TOKEN,
|
||||
label: "Test PC",
|
||||
});
|
||||
const text = buf.slice(2).toString("utf16le");
|
||||
expect(text).toContain(SAMPLE_TOKEN);
|
||||
});
|
||||
|
||||
it("uses token prefix (first 8 chars) in the subkey name and comment", () => {
|
||||
const buf = generateWindowsDohRegFile({
|
||||
deviceId: SAMPLE_DEVICE_ID,
|
||||
dnsToken: SAMPLE_TOKEN,
|
||||
label: "Test PC",
|
||||
});
|
||||
const text = buf.slice(2).toString("utf16le");
|
||||
const prefix = SAMPLE_TOKEN.slice(0, 8); // "abcdef01"
|
||||
expect(text).toContain(`rebreak-${prefix}`);
|
||||
expect(text).toContain(`Token: ${prefix}`);
|
||||
});
|
||||
|
||||
it("contains AutoUpgradeFlag as dword:00000001", () => {
|
||||
const buf = generateWindowsDohRegFile({
|
||||
deviceId: SAMPLE_DEVICE_ID,
|
||||
dnsToken: SAMPLE_TOKEN,
|
||||
label: "Test PC",
|
||||
});
|
||||
const text = buf.slice(2).toString("utf16le");
|
||||
expect(text).toContain('"AutoUpgradeFlag"=dword:00000001');
|
||||
});
|
||||
|
||||
it("uses CRLF line endings throughout", () => {
|
||||
const buf = generateWindowsDohRegFile({
|
||||
deviceId: SAMPLE_DEVICE_ID,
|
||||
dnsToken: SAMPLE_TOKEN,
|
||||
label: "Test PC",
|
||||
});
|
||||
const text = buf.slice(2).toString("utf16le");
|
||||
// Every newline must be preceded by \r
|
||||
const lines = text.split("\r\n");
|
||||
// Rejoining with \r\n and comparing proves all endings were CRLF
|
||||
expect(lines.join("\r\n")).toBe(text);
|
||||
// And there must be multiple lines
|
||||
expect(lines.length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
describe("label escaping", () => {
|
||||
it('escapes double-quote in label', () => {
|
||||
const buf = generateWindowsDohRegFile({
|
||||
deviceId: SAMPLE_DEVICE_ID,
|
||||
dnsToken: SAMPLE_TOKEN,
|
||||
label: 'My "Gaming" PC',
|
||||
});
|
||||
const text = buf.slice(2).toString("utf16le");
|
||||
// In comment line the label appears escaped
|
||||
expect(text).toContain('My \\"Gaming\\" PC');
|
||||
});
|
||||
|
||||
it("escapes backslash in label", () => {
|
||||
const buf = generateWindowsDohRegFile({
|
||||
deviceId: SAMPLE_DEVICE_ID,
|
||||
dnsToken: SAMPLE_TOKEN,
|
||||
label: "C:\\Users\\PC",
|
||||
});
|
||||
const text = buf.slice(2).toString("utf16le");
|
||||
expect(text).toContain("C:\\\\Users\\\\PC");
|
||||
});
|
||||
|
||||
it("escapes newline in label", () => {
|
||||
const buf = generateWindowsDohRegFile({
|
||||
deviceId: SAMPLE_DEVICE_ID,
|
||||
dnsToken: SAMPLE_TOKEN,
|
||||
label: "Line1\nLine2",
|
||||
});
|
||||
const text = buf.slice(2).toString("utf16le");
|
||||
expect(text).toContain("Line1\\nLine2");
|
||||
});
|
||||
});
|
||||
|
||||
it("contains the correct HKLM registry path for DohWellKnownServers", () => {
|
||||
const buf = generateWindowsDohRegFile({
|
||||
deviceId: SAMPLE_DEVICE_ID,
|
||||
dnsToken: SAMPLE_TOKEN,
|
||||
label: "Test PC",
|
||||
});
|
||||
const text = buf.slice(2).toString("utf16le");
|
||||
expect(text).toContain(
|
||||
"HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Services\\Dnscache\\Parameters\\DohWellKnownServers",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("labelToSlug", () => {
|
||||
it("lowercases and replaces non-alphanumeric with hyphens", () => {
|
||||
expect(labelToSlug("Mein PC")).toBe("mein-pc");
|
||||
expect(labelToSlug("Büro-PC 2024")).toBe("b-ro-pc-2024");
|
||||
});
|
||||
|
||||
it("strips leading and trailing hyphens", () => {
|
||||
expect(labelToSlug(" --test-- ")).toBe("test");
|
||||
});
|
||||
|
||||
it("truncates to 40 characters", () => {
|
||||
const long = "a".repeat(50);
|
||||
expect(labelToSlug(long)).toHaveLength(40);
|
||||
});
|
||||
|
||||
it("returns empty string for label with no alphanumeric chars", () => {
|
||||
expect(labelToSlug("---")).toBe("");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user