Files
reverse-engineering-anything/scripts/verify/firmware/esp-image.mjs
T
Entrpi a81b901871 test(artifacts): run ESP lane commands as owned process trees
The lane ran its tools through execFile. A timeout killed only the direct
child, so a linker or assembler that Clang or GCC had started could keep
running while the lane removed its workspace. Run every command through
createVerifierCommands, check the tools inside withVerifierCleanup, and
remove the workspace only after commands.close() has stopped every tree;
a failed close keeps the workspace.

Only a nonzero exit now counts as a refusal or an expected esptool
failure; timeouts and spawn failures propagate. Supervising each command
roughly doubles the lane's run time.
2026-10-28 01:14:22 +11:00

412 lines
13 KiB
JavaScript

#!/usr/bin/env node
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { withVerifierCleanup } from "../../lib/verifier-cleanup.mjs";
import {
assertAgrees,
buildElf,
checkTools,
commands,
elf2image,
execute,
exitOutput,
extract,
imageInfo,
inspect,
options,
source,
spell,
tool,
xtensa,
} from "./esp-image-tools.mjs";
let workspace;
const path = (name) => join(workspace, name);
await withVerifierCleanup(async () => {
const { compilerVersion, esptoolVersion, xtensaVersion } = await checkTools();
workspace = await mkdtemp(join(tmpdir(), "rea-esp-image-proof-"));
const report = {
esptool: esptoolVersion,
compiler: compilerVersion,
images: [],
extractions: [],
};
const app = await buildElf("app.c", "app.ld", path("app.elf"));
const boot = await buildElf(
"bootloader.c",
"bootloader.ld",
path("boot.elf"),
);
const esp8266 = await buildElf(
"bootloader.c",
"esp8266.ld",
path("esp8266.elf"),
);
const appDescriptor = ["--elf-sha256-offset", "0xb0"];
await elf2image("esp32c3", app, path("app.bin"), appDescriptor);
await elf2image("esp32c3", app, path("app-nodigest.bin"), [
...appDescriptor,
"--dont-append-digest",
]);
await elf2image("esp32c3", app, path("app-pad.bin"), [
...appDescriptor,
"--secure-pad-v2",
]);
await elf2image("esp32c3", app, path("app-ram.bin"), ["--ram-only-header"]);
await elf2image("esp32c3", app, path("app-filled.bin"), [
...appDescriptor,
"--pad-to-size",
"1MB",
]);
await elf2image("esp32c3", boot, path("boot.bin"));
await tool("esptool", [
"--chip",
"esp8266",
"elf2image",
"--version",
"1",
"-o",
path("esp8266-"),
esp8266,
]);
// Producer goldens: REA and esptool image-info agree field by field. Each
// class follows from the region app.ld or bootloader.ld places a section in;
// esptool adds the address-0 padding that aligns the flash text.
const appClasses = ["mapped", "loaded", "loaded", "not-loaded", "mapped"];
for (const [name, classes] of [
["app.bin", appClasses],
["app-nodigest.bin", appClasses],
["app-pad.bin", [...appClasses, "not-loaded"]],
["boot.bin", ["loaded", "loaded"]],
]) {
const [oracle, rea] = await Promise.all([
imageInfo(path(name)),
inspect(path(name)),
]);
assertAgrees(name, oracle, rea);
assert.equal(rea.inventory.coverage.status, "complete", name);
assert.deepEqual(
rea.segments.map(
({ format_metadata: segment }) => segment.classification,
),
classes,
`${name} classes`,
);
report.images.push({
name,
role: rea.root.descriptor?.kind ?? "unknown",
segments: rea.segments.map(
({ format_metadata: segment }) =>
`${segment.index}:0x${segment.load_addr.toString(16)}:${segment.classification}`,
),
checksum: rea.root.checksum.status,
sha256: rea.root.appended_sha256.status,
});
}
const padded = await inspect(path("app-pad.bin"));
// --secure-pad-v2 ends the digest on a 64 KiB boundary with an address-0
// padding segment, which ESP-IDF neither maps nor loads.
assert.equal(padded.root.image_end % 0x1_0000, 0);
assert.equal(padded.segments.at(-1).format_metadata.load_addr, 0);
// Signed: blocks are observed; espsecure is the oracle for their key digests.
const generate = spell("generate_signing_key", "generate-signing-key");
const sign = spell("sign_data", "sign-data");
await tool("espsecure", [
generate,
"--version",
"2",
"--scheme",
"rsa3072",
path("rsa.pem"),
]);
for (const scheme of ["ecdsa256", "ecdsa384"])
await tool("espsecure", [
generate,
"--version",
"2",
"--scheme",
scheme,
path(`${scheme}.pem`),
]);
await tool("espsecure", [
sign,
"--version",
"2",
"--keyfile",
path("rsa.pem"),
"--output",
path("signed-rsa.bin"),
path("app-pad.bin"),
]);
// Append one ECDSA P-256 and one P-384 block after the RSA block.
for (const [scheme, input, output] of [
["ecdsa256", "signed-rsa.bin", "signed-p256.bin"],
["ecdsa384", "signed-p256.bin", "signed.bin"],
])
await tool("espsecure", [
sign,
"--version",
"2",
spell("--append_signatures", "--append-signatures"),
"--keyfile",
path(`${scheme}.pem`),
"--output",
path(output),
path(input),
]);
const signatureInfo = await tool("espsecure", [
spell("signature_info_v2", "signature-info-v2"),
path("signed.bin"),
]);
const keyDigests = [
...signatureInfo.matchAll(
/Public key digest for block (\d+): ((?:[0-9a-f]{2} ?){32})/gu,
),
].map((match) => match[2].replaceAll(" ", ""));
const signed = await inspect(path("signed.bin"));
assertAgrees("signed.bin", await imageInfo(path("signed.bin")), signed);
assert.equal(signed.root.signature_sector_offset, padded.root.image_end);
assert.deepEqual(signed.root.undescribed_trailing, []);
assert.equal(signed.inventory.coverage.status, "complete");
assert.deepEqual(
signed.signatures.map(({ format_metadata: block }) => [
block.index,
block.scheme,
block.digest_algorithm,
block.image_digest_matches,
block.public_key_sha256,
block.signature_status,
]),
[
[0, "rsa", "sha256", true, keyDigests[0], "unverified"],
[1, "ecdsa", "sha256", true, keyDigests[1], "unverified"],
[2, "ecdsa", "sha384", true, keyDigests[2], "unverified"],
],
);
report.images.push({
name: "signed.bin",
signatures: signed.signatures.map(
({ format_metadata: block }) =>
`${block.scheme}:${block.public_key_sha256}`,
),
});
// Extraction writes the producer's bytes and leaves the source unchanged.
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
// espsecure appends the signature sector to the --secure-pad-v2 image.
const sectorOffset = (await readFile(path("app-pad.bin"))).length;
for (const name of ["app.bin", "signed.bin"]) {
const bytes = await readFile(path(name));
const digest = sha256(bytes);
const expected = new Map(
(await imageInfo(path(name))).segments.map(
({ index, headerOffset, length }) => [
`segment-${String(index).padStart(2, "0")}.bin`,
bytes.subarray(headerOffset + 8, headerOffset + 8 + length),
],
),
);
if (name === "signed.bin") {
assert.equal(bytes.length, sectorOffset + 0x1000, "signature sector");
for (const slot of [0, 1, 2])
expected.set(
`signature-block-${String(slot)}.bin`,
bytes.subarray(
sectorOffset + slot * 1216,
sectorOffset + (slot + 1) * 1216,
),
);
}
const extraction = await extract(path(name), workspace);
assert.equal(extraction.containment_verified, true, name);
assert.equal(extraction.manifest.root_sha256, digest, `${name} source`);
assert.deepEqual(
extraction.artifacts.map(({ relative_path }) => relative_path).sort(),
[...expected.keys()].sort(),
`${name} entries`,
);
for (const {
relative_path: entry,
sha256: written,
} of extraction.artifacts) {
const content = await readFile(join(extraction.output_root, entry));
assert.ok(content.equals(expected.get(entry)), `${name} ${entry}`);
assert.equal(written, sha256(content), `${name} ${entry} digest`);
}
assert.equal(sha256(await readFile(path(name))), digest, `${name} source`);
report.extractions.push({ name, entries: extraction.artifacts.length });
}
// --ram-only-header counts only RAM segments; the rest is coverage.
const ram = await inspect(path("app-ram.bin"));
const ramOracle = await imageInfo(path("app-ram.bin"));
assertAgrees("app-ram.bin", ramOracle, ram);
const ramBytes = await readFile(path("app-ram.bin"));
assert.equal(ram.inventory.coverage.status, "unknown");
assert.deepEqual(ram.root.undescribed_trailing, [
{
offset: ram.root.image_end,
length: ramBytes.length - ram.root.image_end,
},
]);
report.images.push({
name: "app-ram.bin",
undescribed_trailing: ram.root.undescribed_trailing,
});
// --pad-to-size fills to the size with 0xFF, which describes erased flash.
const filled = await inspect(path("app-filled.bin"));
assertAgrees(
"app-filled.bin",
await imageInfo(path("app-filled.bin")),
filled,
);
assert.equal((await readFile(path("app-filled.bin"))).length, 0x10_0000);
assert.deepEqual(filled.root.undescribed_trailing, []);
assert.equal(filled.inventory.coverage.status, "complete");
report.images.push({ name: "app-filled.bin", coverage: "complete" });
// Malformed cases derived from producer output.
const image = await readFile(path("app.bin"));
const noDigest = await readFile(path("app-nodigest.bin"));
const flipLast = (bytes) => {
const copy = Buffer.from(bytes);
copy[copy.length - 1] ^= 0x01;
return copy;
};
await writeFile(path("bad-checksum.bin"), flipLast(noDigest));
await writeFile(path("bad-sha256.bin"), flipLast(image));
await writeFile(path("truncated.bin"), image.subarray(0, 0x1000));
for (const [name, checksum, sha256] of [
["bad-checksum.bin", "invalid", "not-appended"],
["bad-sha256.bin", "valid", "invalid"],
]) {
const [oracle, rea] = await Promise.all([
imageInfo(path(name)),
inspect(path(name)),
]);
assertAgrees(name, oracle, rea);
assert.equal(rea.root.checksum.status, checksum, name);
assert.equal(rea.root.appended_sha256.status, sha256, name);
report.images.push({ name, checksum, sha256 });
}
const truncatedOracle = await imageInfo(path("truncated.bin")).then(
() => "loaded",
exitOutput,
);
assert.match(truncatedOracle, /End of file reading segment/u);
const truncated = await inspect(path("truncated.bin"));
assert.equal(truncated.inventory.manifest.root_format, "esp-image");
assert.equal(truncated.inventory.coverage.status, "unknown");
assert.equal(truncated.root.checksum.status, "unavailable");
assert.ok(
truncated.inventory.limitations.some((text) =>
text.startsWith("ESP image is truncated: segment "),
),
);
report.images.push({ name: "truncated.bin", coverage: "unknown" });
if (xtensa === undefined)
report.images.push({
name: "app-esp32.bin",
skipped: "REA_XTENSA_ESP32_GCC is unset",
});
else {
await execute(
xtensa,
[
"-nostdlib",
"-static",
"-Os",
"-mlongcalls",
"-mtext-section-literals",
`-Wl,-T,${source("esp32-app.ld")}`,
"-Wl,--build-id=none",
source("app.c"),
"-o",
path("app-esp32.elf"),
],
options,
);
await elf2image(
"esp32",
path("app-esp32.elf"),
path("app-esp32.bin"),
appDescriptor,
);
const [oracle, rea] = await Promise.all([
imageInfo(path("app-esp32.bin")),
inspect(path("app-esp32.bin")),
]);
assertAgrees("app-esp32.bin", oracle, rea);
assert.deepEqual(rea.root.chip, {
target: "esp32",
isa: "xtensa",
source: "ESP-IDF v6.1",
});
assert.deepEqual(
rea.segments.map(
({ format_metadata: segment }) => segment.classification,
),
["mapped", "loaded", "loaded", "not-loaded", "mapped"],
);
report.images.push({
name: "app-esp32.bin",
compiler: xtensaVersion,
segments: rea.segments.map(
({ format_metadata: segment }) =>
`${segment.index}:0x${segment.load_addr.toString(16)}:${segment.classification}`,
),
});
}
// ESP8266 v3 (RTOS SDK) images begin with their IROM segment.
const esp8266App = await buildElf(
"app.c",
"esp8266-app.ld",
path("esp8266-app.elf"),
);
await tool("esptool", [
"--chip",
"esp8266",
"elf2image",
"--version",
"3",
"-o",
path("esp8266-v3.bin"),
esp8266App,
]);
const v3Oracle = await imageInfo(path("esp8266-v3.bin"), "esp8266");
assert.ok((v3Oracle.segments[0]?.address ?? 0) >= 0x4020_0000);
assert.match(
(await inspect(path("esp8266-v3.bin"))).refused ?? "",
/ESP8266 firmware images are not supported/u,
);
report.images.push({ name: "esp8266-v3.bin", refused: true });
// ESP8266 v1: esptool falls back to ESP8266; REA refuses it by name.
const esp8266Image = path("esp8266-0x00000.bin");
assert.equal((await imageInfo(esp8266Image)).detected, "ESP8266");
const refused = await inspect(esp8266Image);
assert.match(
refused.refused ?? "",
/ESP8266 firmware images are not supported/u,
);
report.images.push({ name: "esp8266-0x00000.bin", refused: true });
console.log(JSON.stringify(report, null, 2));
}, [
// Remove outputs only after every owned command tree has stopped; a failed
// close keeps the workspace for inspection.
async () => {
await commands.close();
if (workspace !== undefined)
await rm(workspace, { recursive: true, force: true });
},
]);