mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
Add secure configuration bootstrap flow
This commit is contained in:
@@ -44,12 +44,6 @@ for(const file of walk(dist))
|
||||
if(file.endsWith('.json')) minifyJson(file);
|
||||
}
|
||||
|
||||
for(const file of [ 'renderer-config.json', 'ui-config.json' ])
|
||||
{
|
||||
const target = join(dist, file);
|
||||
if(existsSync(target)) rmSync(target);
|
||||
}
|
||||
|
||||
for(const file of walk(dist))
|
||||
{
|
||||
if(file.endsWith('.js') && !file.endsWith('asset-loader.js')) encryptFile(file);
|
||||
@@ -84,4 +78,4 @@ for(const [ source, file ] of publicLoaderAssets)
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(join(dist, 'index.html'), `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="root"></div><script src="asset-loader.js?v=${ buildVersion }"></script></body></html>`);
|
||||
writeFileSync(join(dist, 'index.html'), `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="root"></div><script src="configuration/bootstrap.js?v=${ buildVersion }"></script></body></html>`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
|
||||
const loader = `(() => {
|
||||
@@ -148,6 +148,10 @@ const loader = `(() => {
|
||||
|
||||
const readClientMode = async () => {
|
||||
try {
|
||||
if(window.__nitroClientMode && typeof window.__nitroClientMode === "object") {
|
||||
debug("loader: client-mode preset");
|
||||
return window.__nitroClientMode;
|
||||
}
|
||||
const url = withCacheBust(new URL("./client-mode.json", getBase()));
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if(!response.ok) throw new Error("client-mode " + response.status);
|
||||
@@ -185,7 +189,157 @@ const loader = `(() => {
|
||||
});
|
||||
})();`;
|
||||
|
||||
const target = resolve('public', 'asset-loader.js');
|
||||
const clientModePath = resolve('public', 'configuration', 'client-mode.json');
|
||||
let bootstrapApiBase = '';
|
||||
|
||||
if(existsSync(clientModePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
const clientMode = JSON.parse(readFileSync(clientModePath, 'utf8'));
|
||||
|
||||
if(typeof clientMode.apiBaseUrl === 'string') bootstrapApiBase = clientMode.apiBaseUrl;
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
const bootstrap = `(() => {
|
||||
const API_BASE = ${ JSON.stringify(bootstrapApiBase) };
|
||||
|
||||
const getBase = () => {
|
||||
const source = document.currentScript?.src || location.href;
|
||||
return new URL(".", source);
|
||||
};
|
||||
|
||||
const withCacheBust = (url) => {
|
||||
url.searchParams.set("v", Date.now().toString(36));
|
||||
return url;
|
||||
};
|
||||
|
||||
const bytesToBase64 = (buffer) => {
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for(let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
const hexValue = (code) => {
|
||||
if(code >= 48 && code <= 57) return code - 48;
|
||||
if(code >= 65 && code <= 70) return code - 55;
|
||||
if(code >= 97 && code <= 102) return code - 87;
|
||||
return -1;
|
||||
};
|
||||
|
||||
const hexToBytes = (hex) => {
|
||||
const normalized = hex.trim();
|
||||
if((normalized.length % 2) !== 0) throw new Error("Invalid encrypted hex payload.");
|
||||
const bytes = new Uint8Array(normalized.length / 2);
|
||||
for(let i = 0; i < bytes.length; i++) {
|
||||
const high = hexValue(normalized.charCodeAt(i * 2));
|
||||
const low = hexValue(normalized.charCodeAt((i * 2) + 1));
|
||||
if(high < 0 || low < 0) throw new Error("Invalid encrypted hex payload.");
|
||||
bytes[i] = (high << 4) | low;
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const deriveAesKey = async (privateKey, serverKeyBase64) => {
|
||||
const serverBytes = Uint8Array.from(atob(serverKeyBase64), char => char.charCodeAt(0));
|
||||
const serverKey = await crypto.subtle.importKey("spki", serverBytes, { name: "ECDH", namedCurve: "P-256" }, false, []);
|
||||
const secret = await crypto.subtle.deriveBits({ name: "ECDH", public: serverKey }, privateKey, 256);
|
||||
const salt = new TextEncoder().encode("nitro-secure-assets-v1");
|
||||
const material = new Uint8Array(secret.byteLength + salt.length);
|
||||
material.set(new Uint8Array(secret), 0);
|
||||
material.set(salt, secret.byteLength);
|
||||
const hash = await crypto.subtle.digest("SHA-256", material);
|
||||
return crypto.subtle.importKey("raw", hash, "AES-GCM", false, ["decrypt"]);
|
||||
};
|
||||
|
||||
const decryptPayload = async (key, response) => {
|
||||
if(response.headers.get("X-Nitro-Sec") !== "1") return response.text();
|
||||
const bytes = hexToBytes(await response.text());
|
||||
if(bytes.length < 13) throw new Error("Encrypted response is too short.");
|
||||
const iv = bytes.slice(0, 12);
|
||||
const payload = bytes.slice(12);
|
||||
const clear = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, payload);
|
||||
return new TextDecoder().decode(clear);
|
||||
};
|
||||
|
||||
const importTextModule = async (sourceText) => {
|
||||
const blobUrl = URL.createObjectURL(new Blob([sourceText], { type: "text/javascript" }));
|
||||
try {
|
||||
await import(blobUrl);
|
||||
} finally {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPlainBootstrap = async () => {
|
||||
const url = withCacheBust(new URL("./asset-loader.js", getBase()));
|
||||
await import(url.href);
|
||||
};
|
||||
|
||||
const loadSecureBootstrap = async () => {
|
||||
if(!API_BASE) throw new Error("Missing apiBaseUrl for secure bootstrap.");
|
||||
|
||||
const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
|
||||
const publicKeyBuffer = await crypto.subtle.exportKey("spki", pair.publicKey);
|
||||
const publicKey = bytesToBase64(publicKeyBuffer);
|
||||
const base = API_BASE.replace(/\\/$/, "");
|
||||
const bootstrapResponse = await fetch(base + "/nitro-sec/bootstrap", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key: publicKey })
|
||||
});
|
||||
|
||||
if(!bootstrapResponse.ok) throw new Error("Secure bootstrap failed: HTTP " + bootstrapResponse.status);
|
||||
|
||||
const bootstrapPayload = await bootstrapResponse.json();
|
||||
if(!bootstrapPayload || typeof bootstrapPayload.key !== "string" || !bootstrapPayload.key.length) {
|
||||
throw new Error("Secure bootstrap returned an invalid server key.");
|
||||
}
|
||||
|
||||
const sessionKey = await deriveAesKey(pair.privateKey, bootstrapPayload.key);
|
||||
|
||||
const fetchSecureConfig = async (file) => {
|
||||
const url = new URL(base + "/nitro-sec/file");
|
||||
url.searchParams.set("kind", "config");
|
||||
url.searchParams.set("file", file);
|
||||
url.searchParams.set("v", Date.now().toString(36));
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: { "X-Nitro-Key": publicKey },
|
||||
cache: "no-store"
|
||||
});
|
||||
|
||||
if(!response.ok) throw new Error("Failed to load secure config " + file + ": HTTP " + response.status);
|
||||
|
||||
return decryptPayload(sessionKey, response);
|
||||
};
|
||||
|
||||
const modeText = await fetchSecureConfig("client-mode.json");
|
||||
window.__nitroClientMode = JSON.parse(modeText);
|
||||
|
||||
const loaderText = await fetchSecureConfig("asset-loader.js");
|
||||
await importTextModule(loaderText);
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await loadSecureBootstrap();
|
||||
} catch(error) {
|
||||
console.warn("[Nitro] Secure bootstrap fallback:", error?.message || error);
|
||||
await loadPlainBootstrap();
|
||||
}
|
||||
})().catch(error => {
|
||||
console.error(error);
|
||||
document.body.textContent = "Unable to load client.";
|
||||
});
|
||||
})();`;
|
||||
|
||||
const target = resolve('public', 'configuration', 'asset-loader.js');
|
||||
const bootstrapTarget = resolve('public', 'configuration', 'bootstrap.js');
|
||||
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, loader);
|
||||
writeFileSync(bootstrapTarget, bootstrap);
|
||||
|
||||
Reference in New Issue
Block a user