Files
Recordly/scripts/macho-content-hash.mjs
webadderall 3e6286cf04 Recordly 2.0 local review stack 1/6
Dependency-ordered review preparation. Not pushed or remotely reviewed.
2026-11-06 05:24:21 +11:00

139 lines
5.4 KiB
JavaScript

import { createHash } from "node:crypto";
const MH_MAGIC_64 = 0xfeedfacf;
const LC_SEGMENT_64 = 0x19;
const LC_CODE_SIGNATURE = 0x1d;
const ZERO_FILL_SECTION_TYPES = new Set([0x1, 0xc, 0x12]);
function fail(message) {
throw new Error(`Cannot hash Mach-O content: ${message}`);
}
function fileInteger(bytes, offset) {
const value = bytes.readBigUInt64LE(offset);
if (value > BigInt(bytes.length)) fail("file range exceeds the binary");
return Number(value);
}
function name(bytes, offset) {
return bytes
.subarray(offset, offset + 16)
.toString("ascii")
.replace(/\0.*$/, "");
}
/**
* Hash a thin, little-endian 64-bit Mach-O executable independently of codesign.
* All non-signature load commands and file payload bytes remain authenticated.
* Only signing's load command/blob, zero header/alignment padding, and LINKEDIT
* allocation sizes are omitted. This does not validate or replace a code signature.
*/
export function machoContentHash(input) {
const bytes = Buffer.isBuffer(input) ? input : Buffer.from(input);
if (bytes.length < 32 || bytes.readUInt32LE(0) !== MH_MAGIC_64) {
fail("expected a thin little-endian 64-bit Mach-O binary");
}
const commandCount = bytes.readUInt32LE(16);
const commandBytes = bytes.readUInt32LE(20);
const commandEnd = 32 + commandBytes;
if (commandEnd > bytes.length || commandCount > Math.floor(commandBytes / 8)) {
fail("invalid load-command table");
}
const commands = [];
const fileSegments = [];
let firstSectionOffset = bytes.length;
let lastSectionEnd = 0;
let signature = null;
let linkedit = null;
let offset = 32;
for (let index = 0; index < commandCount; index++) {
if (offset + 8 > commandEnd) fail("truncated load command");
const command = bytes.readUInt32LE(offset);
const size = bytes.readUInt32LE(offset + 4);
if (size < 8 || size % 8 !== 0 || offset + size > commandEnd) {
fail("invalid load-command size");
}
const normalized = Buffer.from(bytes.subarray(offset, offset + size));
if (command === LC_CODE_SIGNATURE) {
if (signature || size !== 16) fail("invalid or duplicate signature command");
signature = {
offset: bytes.readUInt32LE(offset + 8),
size: bytes.readUInt32LE(offset + 12),
};
} else {
if (command === LC_SEGMENT_64) {
if (size < 72) fail("truncated segment command");
const sectionCount = bytes.readUInt32LE(offset + 64);
if (size !== 72 + sectionCount * 80) fail("invalid segment section table");
const segmentName = name(bytes, offset + 8);
const fileOffset = fileInteger(bytes, offset + 40);
const fileSize = fileInteger(bytes, offset + 48);
if (fileOffset + fileSize > bytes.length) fail("segment exceeds the binary");
if (segmentName === "__LINKEDIT") {
if (linkedit) fail("duplicate LINKEDIT segment");
linkedit = { offset: fileOffset, size: fileSize };
// codesign adjusts only this segment's allocated virtual/file size.
normalized.fill(0, 32, 40);
normalized.fill(0, 48, 56);
} else if (fileSize) {
fileSegments.push({ offset: fileOffset, size: fileSize });
}
for (let section = 0; section < sectionCount; section++) {
const sectionOffset = offset + 72 + section * 80;
const sectionType = bytes.readUInt32LE(sectionOffset + 64) & 0xff;
if (ZERO_FILL_SECTION_TYPES.has(sectionType)) continue;
const sectionSize = fileInteger(bytes, sectionOffset + 40);
const sectionFileOffset = bytes.readUInt32LE(sectionOffset + 48);
if (!sectionSize) continue;
if (
sectionFileOffset < commandEnd ||
sectionFileOffset + sectionSize > bytes.length ||
sectionFileOffset < fileOffset ||
sectionFileOffset + sectionSize > fileOffset + fileSize
)
fail("section is outside its file-backed segment");
firstSectionOffset = Math.min(firstSectionOffset, sectionFileOffset);
lastSectionEnd = Math.max(lastSectionEnd, sectionFileOffset + sectionSize);
}
}
commands.push(normalized);
}
offset += size;
}
if (offset !== commandEnd || firstSectionOffset === bytes.length) {
fail("invalid command table or no file-backed sections");
}
if (bytes.subarray(commandEnd, firstSectionOffset).some((byte) => byte !== 0)) {
fail("nonzero header padding cannot be excluded");
}
let payloadEnd = bytes.length;
if (signature) {
if (
!signature.size ||
!linkedit ||
signature.offset < linkedit.offset ||
signature.offset + signature.size !== bytes.length ||
signature.offset + signature.size > linkedit.offset + linkedit.size ||
signature.offset < lastSectionEnd ||
fileSegments.some((segment) => segment.offset + segment.size > signature.offset)
)
fail("signature is not a terminal LINKEDIT blob outside executable/data segments");
payloadEnd = signature.offset;
}
// Signing may add zero bytes to align the terminal signature. Zero file-tail
// padding has no content; section/load-command sizes remain part of the digest.
while (payloadEnd > firstSectionOffset && bytes[payloadEnd - 1] === 0) payloadEnd--;
const header = Buffer.from(bytes.subarray(0, 32));
header.writeUInt32LE(commands.length, 16);
header.writeUInt32LE(
commands.reduce((total, command) => total + command.length, 0),
20,
);
const hash = createHash("sha256");
hash.update("Recordly Mach-O content v1\0");
hash.update(header);
for (const command of commands) hash.update(command);
hash.update(bytes.subarray(firstSectionOffset, payloadEnd));
return hash.digest("hex");
}