diff --git a/_data/navigation.yml b/_data/navigation.yml index 6f503d0b..24900581 100644 --- a/_data/navigation.yml +++ b/_data/navigation.yml @@ -49,6 +49,8 @@ docs: url: /changelog/dev.html - title: 其他 children: + - title: 验证 HMCL + url: /verify.html - title: 用户协议 url: /eula/hmcl.html - title: 贡献指南 diff --git a/_pages/verify.md b/_pages/verify.md new file mode 100644 index 00000000..623492fe --- /dev/null +++ b/_pages/verify.md @@ -0,0 +1,98 @@ +--- +title: 验证 HMCL 文件 +permalink: /verify.html +toc: false +--- + +
+

检查 HMCL 文件是否是官方构建。

+ +
+ + 未选择文件 + +
+ + +
+ + + + diff --git a/assets/js/hmcl-signature-verify.js b/assets/js/hmcl-signature-verify.js new file mode 100644 index 00000000..a8ea3b3d --- /dev/null +++ b/assets/js/hmcl-signature-verify.js @@ -0,0 +1,407 @@ +(function () { + "use strict"; + + const SIGNATURE_ENTRY_NAME = "META-INF/hmcl_signature"; + const PUBLIC_KEY_ENTRY_NAME = "assets/hmcl_signature_publickey.der"; + const LAUNCHER_ENTRY_NAMES = ["assets/HMCLauncher.exe", "assets/HMCLauncher.sh"]; + const PUBLIC_KEY_DER_BASE64 = "MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAypkXnIxC3KzQsICAK9lEan3icR8OUREbW1vqANzi7ne0EXAyFh1Ow10HIY+SIWHVhsIkS/mvNyUyW5TIPc22djWNQKf6OukOY2/htJpolj4l/uhPGe4BoFiV8m2jvyKq0X7truazc9/BDrLKkoIVIkXb0PenVs1AlFLnXzLEu9Lj4kxfkRO4zdV4CMHYWLWLLP9Dj0/Xg+tj9n/QX/IWVVrBporKiJgg99WztKw7wQ/Zd+Syu8vfa6IjpgRojtOiCb8MUvEUHcs8RG/PPRACJ8bNwCaNc0RNLoyfVGjjZGFh95XPr6huJYmn/11j45GTB9I5w77JnF83AWwAPyx15z6yHKm4epzXJ9yFrTK5nATQGD1H2i8rdg4ZvsjFE4nxEYTWDTkk9nQdZs3n5Os+pUh8hyyjsa4Hh2yjmfcivcE/KzyP8JH15OWH9QOHwdfRTDu3v4AxSZCuQKlhaSpYbHFahE+jqP+Pr0+Bc/e5ybu6SdAELlOJrVYU6pkgrvYJmkV6Ahfm3P8OZKt72MAGxDfdYNUA70QWMMC1YP+GQYedC+oLA4/BhQ1Su9AfUUdQGN0a8a6uaZFX5QyiA/6KrRUC06dQdXi9ZOCx8LY28Snl7TjgmOef15HnnmmQTbARJuR0eenprzOXJXJzkS6/Vx1ak/9gNRA8yMiVM9lIilsCAwEAAQ=="; + const PUBLIC_KEY_DER = base64ToUint8Array(PUBLIC_KEY_DER_BASE64); + + const VerificationErrorCode = Object.freeze({ + MISSING_PUBLIC_KEY: "missing-public-key", + MISSING_SIGNATURE: "missing-signature", + INVALID_PUBLIC_KEY: "invalid-public-key", + INVALID_SIGNATURE: "invalid-signature", + INVALID_LAUNCHER_HEADER: "invalid-launcher-header", + INVALID_ZIP: "invalid-zip", + UNSUPPORTED_ZIP: "unsupported-zip", + UNSUPPORTED_BROWSER: "unsupported-browser", + }); + + class VerificationError extends Error { + constructor(code, message, cause) { + super(message); + this.name = "VerificationError"; + this.code = code; + this.cause = cause; + } + } + + const fileInput = document.getElementById("hmcl-verify-file"); + const resultElement = document.getElementById("hmcl-verify-result"); + const selectedFileElement = document.getElementById("hmcl-verify-selected-file"); + let verificationRequestId = 0; + + if (!fileInput || !resultElement) { + return; + } + + fileInput.addEventListener("change", async () => { + const requestId = ++verificationRequestId; + const file = fileInput.files && fileInput.files[0]; + if (!file) { + setSelectedFileName(""); + hideResult(); + return; + } + + setSelectedFileName(file.name); + setResult("正在验证,请稍候..."); + + try { + await verifyHmclFile(file); + if (!isCurrentVerification(requestId)) { + return; + } + setResult( + "验证通过,该文件是 HMCL 的官方构建,可以放心使用。", + "ok" + ); + } catch (error) { + if (!isCurrentVerification(requestId)) { + return; + } + setResult(`验证失败。\n${formatVerificationError(error)}`, "error"); + } + }); + + function isCurrentVerification(requestId) { + return requestId === verificationRequestId; + } + + async function verifyHmclFile(file) { + const fileBuffer = await file.arrayBuffer(); + const archive = readZipArchive(fileBuffer); + const entries = archive.entries; + const publicKeyEntry = entries.find((entry) => entry.name === PUBLIC_KEY_ENTRY_NAME); + const signatureEntry = entries.find((entry) => entry.name === SIGNATURE_ENTRY_NAME); + + if (!publicKeyEntry) { + throw new VerificationError(VerificationErrorCode.MISSING_PUBLIC_KEY, `missing ${PUBLIC_KEY_ENTRY_NAME}`); + } + + if (!signatureEntry) { + throw new VerificationError(VerificationErrorCode.MISSING_SIGNATURE, `missing ${SIGNATURE_ENTRY_NAME}`); + } + + const bundledPublicKey = await readEntryContent(fileBuffer, publicKeyEntry); + if (!bytesEqual(bundledPublicKey, PUBLIC_KEY_DER)) { + throw new VerificationError(VerificationErrorCode.INVALID_PUBLIC_KEY, "public key mismatch"); + } + + const signature = await readEntryContent(fileBuffer, signatureEntry); + const signedEntries = entries + .filter((entry) => entry.name !== SIGNATURE_ENTRY_NAME) + .sort((left, right) => { + if (left.name < right.name) { + return -1; + } + if (left.name > right.name) { + return 1; + } + return 0; + }); + + const signedDataChunks = []; + for (const entry of signedEntries) { + const nameBytes = new TextEncoder().encode(entry.name); + const content = await readEntryContent(fileBuffer, entry); + signedDataChunks.push(await sha512(nameBytes)); + signedDataChunks.push(await sha512(content)); + } + + const publicKey = await crypto.subtle.importKey( + "spki", + PUBLIC_KEY_DER, + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-512", + }, + false, + ["verify"] + ); + + const valid = await crypto.subtle.verify( + "RSASSA-PKCS1-v1_5", + publicKey, + signature, + concatUint8Arrays(signedDataChunks) + ); + + if (!valid) { + throw new VerificationError(VerificationErrorCode.INVALID_SIGNATURE, "invalid signature"); + } + + await verifyLauncherPrefix(fileBuffer, archive); + } + + function readZipArchive(buffer) { + const view = new DataView(buffer); + const end = findEndOfCentralDirectory(view); + const totalEntries = view.getUint16(end + 10, true); + const centralDirectorySize = view.getUint32(end + 12, true); + const centralDirectoryOffset = view.getUint32(end + 16, true); + + if (centralDirectorySize === 0xffffffff || centralDirectoryOffset === 0xffffffff) { + throw new VerificationError(VerificationErrorCode.UNSUPPORTED_ZIP, "Zip64 is not supported"); + } + + const zipStartOffset = end - centralDirectorySize - centralDirectoryOffset; + if (zipStartOffset < 0) { + throw new VerificationError(VerificationErrorCode.INVALID_ZIP, "invalid zip offsets"); + } + + const entries = []; + let offset = zipStartOffset + centralDirectoryOffset; + const centralDirectoryEnd = offset + centralDirectorySize; + + for (let index = 0; index < totalEntries; index += 1) { + if (offset + 46 > view.byteLength || view.getUint32(offset, true) !== 0x02014b50) { + throw new VerificationError(VerificationErrorCode.INVALID_ZIP, "invalid central directory"); + } + + const flags = view.getUint16(offset + 8, true); + const method = view.getUint16(offset + 10, true); + const compressedSize = view.getUint32(offset + 20, true); + const uncompressedSize = view.getUint32(offset + 24, true); + const fileNameLength = view.getUint16(offset + 28, true); + const extraLength = view.getUint16(offset + 30, true); + const commentLength = view.getUint16(offset + 32, true); + const localHeaderOffset = view.getUint32(offset + 42, true); + const fileNameStart = offset + 46; + const fileNameEnd = fileNameStart + fileNameLength; + const name = decodeEntryName(new Uint8Array(view.buffer, fileNameStart, fileNameLength), flags); + + entries.push({ + name, + method, + compressedSize, + uncompressedSize, + localHeaderOffset: zipStartOffset + localHeaderOffset, + }); + + offset = fileNameEnd + extraLength + commentLength; + } + + if (offset !== centralDirectoryEnd) { + throw new VerificationError(VerificationErrorCode.INVALID_ZIP, "central directory size mismatch"); + } + + return { + entries, + zipStartOffset, + }; + } + + function findEndOfCentralDirectory(view) { + const minOffset = Math.max(0, view.byteLength - 22 - 0xffff); + + for (let offset = view.byteLength - 22; offset >= minOffset; offset -= 1) { + if (view.getUint32(offset, true) === 0x06054b50) { + const commentLength = view.getUint16(offset + 20, true); + if (offset + 22 + commentLength === view.byteLength) { + return offset; + } + } + } + + throw new VerificationError(VerificationErrorCode.INVALID_ZIP, "end of central directory not found"); + } + + async function readEntryContent(buffer, entry) { + const view = new DataView(buffer); + const offset = entry.localHeaderOffset; + + if (offset + 30 > view.byteLength || view.getUint32(offset, true) !== 0x04034b50) { + throw new VerificationError(VerificationErrorCode.INVALID_ZIP, `invalid local header: ${entry.name}`); + } + + const fileNameLength = view.getUint16(offset + 26, true); + const extraLength = view.getUint16(offset + 28, true); + const dataStart = offset + 30 + fileNameLength + extraLength; + const dataEnd = dataStart + entry.compressedSize; + + if (dataEnd > view.byteLength) { + throw new VerificationError(VerificationErrorCode.INVALID_ZIP, `entry data is truncated: ${entry.name}`); + } + + const compressedData = new Uint8Array(buffer, dataStart, entry.compressedSize); + + if (entry.method === 0) { + return compressedData; + } + + if (entry.method === 8) { + return inflateRaw(compressedData, entry.name, entry.uncompressedSize); + } + + throw new VerificationError( + VerificationErrorCode.UNSUPPORTED_ZIP, + `unsupported compression method ${entry.method}: ${entry.name}` + ); + } + + async function verifyLauncherPrefix(buffer, archive) { + const prefixLength = archive.zipStartOffset; + + if (prefixLength === 0) { + return; + } + + const prefix = new Uint8Array(buffer, 0, prefixLength); + + for (const entryName of LAUNCHER_ENTRY_NAMES) { + const launcherEntry = archive.entries.find((entry) => entry.name === entryName); + if (!launcherEntry) { + continue; + } + + const launcherContent = await readEntryContent(buffer, launcherEntry); + if (bytesEqual(prefix, launcherContent)) { + return; + } + } + + throw new VerificationError( + VerificationErrorCode.INVALID_LAUNCHER_HEADER, + "launcher header does not match signed assets" + ); + } + + async function inflateRaw(data, entryName, expectedSize) { + if (typeof DecompressionStream === "undefined") { + throw new VerificationError( + VerificationErrorCode.UNSUPPORTED_BROWSER, + "this browser does not support DecompressionStream" + ); + } + + let stream; + try { + stream = new Blob([data]).stream().pipeThrough(new DecompressionStream("deflate-raw")); + } catch (error) { + throw new VerificationError( + VerificationErrorCode.UNSUPPORTED_BROWSER, + `this browser cannot decompress deflated zip entries: ${entryName}`, + error + ); + } + + let output; + try { + output = new Uint8Array(await new Response(stream).arrayBuffer()); + } catch (error) { + throw new VerificationError( + VerificationErrorCode.INVALID_ZIP, + `cannot decompress entry: ${entryName}`, + error + ); + } + + if (output.byteLength !== expectedSize) { + throw new VerificationError( + VerificationErrorCode.INVALID_ZIP, + `uncompressed size mismatch: ${entryName}` + ); + } + return output; + } + + function decodeEntryName(bytes, flags) { + if ((flags & 0x0800) === 0) { + return new TextDecoder("utf-8").decode(bytes); + } + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } + + async function sha512(data) { + return new Uint8Array(await crypto.subtle.digest("SHA-512", data)); + } + + function concatUint8Arrays(chunks) { + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + + return result; + } + + function bytesEqual(left, right) { + if (left.byteLength !== right.byteLength) { + return false; + } + + for (let index = 0; index < left.byteLength; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + + return true; + } + + function base64ToUint8Array(base64) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + return bytes; + } + + function formatVerificationError(error) { + switch (error && error.code) { + case VerificationErrorCode.MISSING_PUBLIC_KEY: + return "这不是可验证的 HMCL 文件,或者 HMCL 版本过低,无法验证。"; + + case VerificationErrorCode.MISSING_SIGNATURE: + return "这是非官方构建,请谨慎甄别其来源。"; + + case VerificationErrorCode.INVALID_PUBLIC_KEY: + case VerificationErrorCode.INVALID_SIGNATURE: + case VerificationErrorCode.INVALID_LAUNCHER_HEADER: + return "该 HMCL 文件可能被篡改或已损坏,请不要使用此文件。你可以从 HMCL 官方网站重新下载 HMCL。"; + + case VerificationErrorCode.INVALID_ZIP: + return "这个文件不是有效的 HMCL 文件,或文件已经损坏。"; + + case VerificationErrorCode.UNSUPPORTED_ZIP: + return "暂不支持验证这种文件格式。"; + + case VerificationErrorCode.UNSUPPORTED_BROWSER: + return "当前浏览器不支持读取这个文件,请换用新版 Chrome、Edge 或 Firefox。"; + + default: + return "无法完成验证,请确认你选择的是从官方渠道下载的 HMCL 文件。"; + } + } + + function setResult(message, state) { + resultElement.textContent = message; + resultElement.hidden = false; + if (state) { + resultElement.dataset.state = state; + } else { + delete resultElement.dataset.state; + } + } + + function hideResult() { + resultElement.textContent = ""; + resultElement.hidden = true; + delete resultElement.dataset.state; + } + + function setSelectedFileName(fileName) { + if (selectedFileElement) { + selectedFileElement.textContent = fileName || "未选择文件"; + } + } +})();