From f37b7e065086bb8a766b39bd61895a95b318b701 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:32:13 -0700 Subject: [PATCH 1/2] feat(events): add signed firmware OTA contracts --- .env.example | 4 +- .github/workflows/ci.yml | 3 + data/eventFirmwareOTA.json | 47 +++++++ package.json | 3 +- src/index.ts | 2 + src/lib/eventFirmwareOTA.ts | 236 +++++++++++++++++++++++++++++++++ src/routes/eventFirmwareOTA.ts | 28 ++++ src/routes/index.ts | 1 + tests/eventFirmwareOTA.test.ts | 129 ++++++++++++++++++ 9 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 data/eventFirmwareOTA.json create mode 100644 src/lib/eventFirmwareOTA.ts create mode 100644 src/routes/eventFirmwareOTA.ts create mode 100644 tests/eventFirmwareOTA.test.ts diff --git a/.env.example b/.env.example index 0a7ccb0..60f434e 100644 --- a/.env.example +++ b/.env.example @@ -9,4 +9,6 @@ DATABASE_URL= MQTT_URL= MQTT_USERNAME= MQTT_PASSWORD= -MQTT_ROOT_TOPIC= \ No newline at end of file +MQTT_ROOT_TOPIC= +EVENT_FIRMWARE_SIGNING_KEY_ID= +EVENT_FIRMWARE_SIGNING_PRIVATE_KEY_PEM= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bee7f1..7d39c8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,3 +47,6 @@ jobs: - name: Build Package run: pnpm build + + - name: Run Tests + run: pnpm test diff --git a/data/eventFirmwareOTA.json b/data/eventFirmwareOTA.json new file mode 100644 index 0000000..0e6ef75 --- /dev/null +++ b/data/eventFirmwareOTA.json @@ -0,0 +1,47 @@ +{ + "version": 1, + "contracts": [ + { + "schemaVersion": 1, + "releaseId": "defcon34-2.8.0.b00d76f", + "edition": "DEFCON", + "version": "2.8.0.b00d76f", + "issuedAt": "2026-07-29T00:00:00Z", + "expiresAt": "2026-10-01T00:00:00Z", + "artifacts": [ + { + "pioEnv": "tbeam-s3-core", + "hwModel": 12, + "architecture": "esp32-s3", + "version": "2.8.0.b00d76f", + "format": "bin", + "url": "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/f6688533ba0c57f33d42d99958656d3e8a2057e6/event/defcon34/firmware-2.8.0.b00d76f/firmware-tbeam-s3-core-2.8.0.b00d76f.bin", + "sha256": "bab5253d557d424802637f5dca301268d1122f559f10a6f9fb3d21b8197fdcef", + "byteCount": 2383792, + "minimumSourceVersion": "2.7.26", + "partitionRole": "app0", + "partitionScheme": "8MB", + "dfuProtocol": null, + "minimumBootloaderVersion": null + } + ], + "standardArtifacts": [ + { + "pioEnv": "tbeam-s3-core", + "hwModel": 12, + "architecture": "esp32-s3", + "version": "2.7.26.54e0d8d", + "format": "bin", + "url": "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/869f193161a1d03460d901ad827483871d74f692/firmware-2.7.26.54e0d8d/firmware-tbeam-s3-core-2.7.26.54e0d8d.bin", + "sha256": "64c1e3f797625da70f7c55794f78986e84515213a0192289ce4e6bef8ae543b4", + "byteCount": 2213168, + "minimumSourceVersion": "2.7.26", + "partitionRole": "app0", + "partitionScheme": "8MB", + "dfuProtocol": null, + "minimumBootloaderVersion": null + } + ] + } + ] +} diff --git a/package.json b/package.json index 62531d9..35c1a90 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "scripts": { "dev": "tsx src/index.ts", "start": "prisma migrate deploy && node dist/index.js", - "build": "prisma generate && tsc" + "build": "prisma generate && tsc", + "test": "tsx --test tests/**/*.test.ts" }, "dependencies": { "@buf/meshtastic_api.bufbuild_es": "1.7.2-20240110092216-3a147af14302.1", diff --git a/src/index.ts b/src/index.ts index e486fc6..bc7e36a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { RegisterMqttClient } from "./lib/index.js"; import { DeviceLinksRoutes, EventFirmwareIconRoutes, + EventFirmwareOTARoutes, EventFirmwareRoutes, FirmwareRoutes, GithubRoutes, @@ -86,6 +87,7 @@ GithubRoutes(); ResourceRoutes(); DeviceLinksRoutes(); EventFirmwareRoutes(); +EventFirmwareOTARoutes(); EventFirmwareIconRoutes(); UpdaterRoutes(); MqttRoutes(); diff --git a/src/lib/eventFirmwareOTA.ts b/src/lib/eventFirmwareOTA.ts new file mode 100644 index 0000000..a9d1637 --- /dev/null +++ b/src/lib/eventFirmwareOTA.ts @@ -0,0 +1,236 @@ +import { createPrivateKey, sign as createSignature } from "node:crypto"; +import { readFileSync } from "node:fs"; + +const DATA_PATH = new URL("../../data/eventFirmwareOTA.json", import.meta.url); +const EDITION_RE = /^[A-Z][A-Z0-9_]{0,63}$/; +const IDENTIFIER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +const SHA256_RE = /^[0-9a-f]{64}$/i; +const VERSION_RE = /^\d+\.\d+\.\d+(?:\.[0-9A-Za-z_-]+)?$/; +const ISO8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; +const IMMUTABLE_RAW_GITHUB_RE = + /^\/meshtastic\/meshtastic\.github\.io\/[0-9a-f]{40}\/.+/i; +const ESP32_ARCHITECTURES = new Set([ + "esp32", + "esp32-c3", + "esp32-c6", + "esp32-s3", +]); + +export interface EventFirmwareOTAEnvelope { + keyId: string; + payload: string; + signature: string; +} + +export interface EventFirmwareOTAArtifact { + pioEnv: string; + hwModel: number; + architecture: string; + version: string; + format: "bin" | "otaZip"; + url: string; + sha256: string; + byteCount: number; + minimumSourceVersion: string; + partitionRole: string | null; + partitionScheme: string | null; + dfuProtocol: string | null; + minimumBootloaderVersion: string | null; +} + +export interface EventFirmwareOTAContract { + schemaVersion: number; + releaseId: string; + edition: string; + version: string; + issuedAt: string; + expiresAt: string; + artifacts: EventFirmwareOTAArtifact[]; + standardArtifacts: EventFirmwareOTAArtifact[]; +} + +interface EventFirmwareOTAData { + version: number; + contracts: EventFirmwareOTAContract[]; +} + +let cached: EventFirmwareOTAData | null = null; + +const fail = (message: string): never => { + throw new Error(`Invalid event firmware OTA contract: ${message}`); +}; + +const validateArtifact = (artifact: EventFirmwareOTAArtifact): void => { + if (!artifact.pioEnv || !Number.isSafeInteger(artifact.hwModel)) { + fail("artifact target is incomplete"); + } + if ( + !VERSION_RE.test(artifact.version) || + !VERSION_RE.test(artifact.minimumSourceVersion) || + !SHA256_RE.test(artifact.sha256) || + !Number.isSafeInteger(artifact.byteCount) || + artifact.byteCount <= 0 + ) { + fail("artifact integrity metadata is invalid"); + } + + let url: URL; + try { + url = new URL(artifact.url); + } catch { + throw new Error( + "Invalid event firmware OTA contract: artifact URL is invalid", + ); + } + if ( + url.protocol !== "https:" || + url.hostname !== "raw.githubusercontent.com" || + url.username || + url.password || + url.search || + url.hash || + !IMMUTABLE_RAW_GITHUB_RE.test(url.pathname) + ) { + fail("artifact URL is not an approved immutable release URL"); + } + + if (ESP32_ARCHITECTURES.has(artifact.architecture)) { + if ( + artifact.format !== "bin" || + artifact.partitionRole !== "app0" || + !artifact.partitionScheme || + artifact.dfuProtocol !== null || + artifact.minimumBootloaderVersion !== null || + artifact.byteCount > 16 * 1024 * 1024 + ) { + fail("ESP32 artifact metadata is incompatible"); + } + return; + } + + if (artifact.architecture === "nrf52840") { + if ( + artifact.format !== "otaZip" || + artifact.partitionRole !== null || + artifact.partitionScheme !== null || + artifact.dfuProtocol !== "nordic-legacy" || + !artifact.minimumBootloaderVersion || + artifact.byteCount > 4 * 1024 * 1024 + ) { + fail("nRF artifact metadata is incompatible"); + } + return; + } + + fail("artifact architecture is not supported for in-app OTA"); +}; + +const validateArtifactSet = ( + name: string, + artifacts: EventFirmwareOTAArtifact[], +): void => { + const targets = new Set(); + for (const artifact of artifacts) { + validateArtifact(artifact); + const target = [ + artifact.pioEnv, + artifact.hwModel, + artifact.architecture, + ].join(":"); + if (targets.has(target)) { + fail(`${name} contains a duplicate exact target`); + } + targets.add(target); + } +}; + +export const validateEventFirmwareOTAContract = ( + contract: EventFirmwareOTAContract, +): void => { + if ( + contract.schemaVersion !== 1 || + !IDENTIFIER_RE.test(contract.releaseId) || + !EDITION_RE.test(contract.edition) || + !VERSION_RE.test(contract.version) + ) { + fail("release identity is invalid"); + } + + if ( + !ISO8601_RE.test(contract.issuedAt) || + !ISO8601_RE.test(contract.expiresAt) + ) { + fail("validity timestamps must use UTC ISO-8601 seconds"); + } + const issuedAt = Date.parse(contract.issuedAt); + const expiresAt = Date.parse(contract.expiresAt); + if ( + !Number.isFinite(issuedAt) || + !Number.isFinite(expiresAt) || + expiresAt <= issuedAt + ) { + fail("validity window is invalid"); + } + if ( + !Array.isArray(contract.artifacts) || + !Array.isArray(contract.standardArtifacts) || + contract.artifacts.length === 0 || + contract.standardArtifacts.length === 0 + ) { + fail("event and standard artifact sets are required"); + } + + validateArtifactSet("artifacts", contract.artifacts); + validateArtifactSet("standardArtifacts", contract.standardArtifacts); +}; + +const getEventFirmwareOTAData = (): EventFirmwareOTAData => { + if (!cached) { + cached = JSON.parse( + readFileSync(DATA_PATH, "utf8"), + ) as EventFirmwareOTAData; + if (cached.version !== 1 || !Array.isArray(cached.contracts)) { + fail("data envelope is invalid"); + } + for (const contract of cached.contracts) { + validateEventFirmwareOTAContract(contract); + } + } + return cached; +}; + +export const findEventFirmwareOTAContract = ( + contracts: EventFirmwareOTAContract[], + edition: string, +): EventFirmwareOTAContract | null => { + const normalized = edition.toUpperCase(); + if (!EDITION_RE.test(normalized)) return null; + return contracts.find((contract) => contract.edition === normalized) ?? null; +}; + +export const getEventFirmwareOTAContract = ( + edition: string, +): EventFirmwareOTAContract | null => + findEventFirmwareOTAContract(getEventFirmwareOTAData().contracts, edition); + +export const signEventFirmwareOTAContract = ( + contract: EventFirmwareOTAContract, + keyId: string, + privateKeyPem: string, +): EventFirmwareOTAEnvelope => { + validateEventFirmwareOTAContract(contract); + if (!IDENTIFIER_RE.test(keyId)) { + fail("signing key identifier is invalid"); + } + + const privateKey = createPrivateKey(privateKeyPem); + if (privateKey.asymmetricKeyType !== "ed25519") { + fail("signing key must be Ed25519"); + } + const payload = Buffer.from(JSON.stringify(contract)); + return { + keyId, + payload: payload.toString("base64"), + signature: createSignature(null, payload, privateKey).toString("base64"), + }; +}; diff --git a/src/routes/eventFirmwareOTA.ts b/src/routes/eventFirmwareOTA.ts new file mode 100644 index 0000000..733eb4c --- /dev/null +++ b/src/routes/eventFirmwareOTA.ts @@ -0,0 +1,28 @@ +import { app } from "../index.js"; +import { + getEventFirmwareOTAContract, + signEventFirmwareOTAContract, +} from "../lib/eventFirmwareOTA.js"; + +export const EventFirmwareOTARoutes = () => + app.get("resource/eventFirmware/:edition/ota", (req, res) => { + const contract = getEventFirmwareOTAContract(req.params.edition ?? ""); + if (!contract) return res.sendStatus(404); + + const keyId = process.env.EVENT_FIRMWARE_SIGNING_KEY_ID; + const privateKey = process.env.EVENT_FIRMWARE_SIGNING_PRIVATE_KEY_PEM; + if (!keyId || !privateKey) { + console.error("Event firmware OTA signing key is not configured"); + return res.sendStatus(503); + } + + try { + res.setHeader("Cache-Control", "no-store"); + return res.json( + signEventFirmwareOTAContract(contract, keyId, privateKey), + ); + } catch (error) { + console.error("eventFirmwareOTA", error); + return res.sendStatus(502); + } + }); diff --git a/src/routes/index.ts b/src/routes/index.ts index 482c8a4..30c40ad 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,6 +1,7 @@ export { DeviceLinksRoutes } from "./deviceLinks.js"; export { EventFirmwareRoutes } from "./eventFirmware.js"; export { EventFirmwareIconRoutes } from "./eventFirmwareIcon.js"; +export { EventFirmwareOTARoutes } from "./eventFirmwareOTA.js"; export { FirmwareRoutes } from "./firmware.js"; export { GithubRoutes } from "./github.js"; export { MqttRoutes } from "./mqtt.js"; diff --git a/tests/eventFirmwareOTA.test.ts b/tests/eventFirmwareOTA.test.ts new file mode 100644 index 0000000..a6e78f9 --- /dev/null +++ b/tests/eventFirmwareOTA.test.ts @@ -0,0 +1,129 @@ +import { strict as assert } from "node:assert"; +import { generateKeyPairSync, verify as verifySignature } from "node:crypto"; +import { describe, it } from "node:test"; +import { + type EventFirmwareOTAContract, + findEventFirmwareOTAContract, + signEventFirmwareOTAContract, + validateEventFirmwareOTAContract, +} from "../src/lib/eventFirmwareOTA.js"; + +const artifact = { + pioEnv: "tbeam-s3-core", + hwModel: 12, + architecture: "esp32-s3", + version: "2.8.0.b00d76f", + format: "bin" as const, + url: "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/f6688533ba0c57f33d42d99958656d3e8a2057e6/event/defcon34/firmware.bin", + sha256: "a".repeat(64), + byteCount: 2_383_792, + minimumSourceVersion: "2.7.26", + partitionRole: "app0", + partitionScheme: "8MB", + dfuProtocol: null, + minimumBootloaderVersion: null, +}; + +const contract: EventFirmwareOTAContract = { + schemaVersion: 1, + releaseId: "defcon34-2.8.0.b00d76f", + edition: "DEFCON", + version: "2.8.0.b00d76f", + issuedAt: "2026-07-29T00:00:00Z", + expiresAt: "2026-10-01T00:00:00Z", + artifacts: [artifact], + standardArtifacts: [ + { + ...artifact, + version: "2.7.26.54e0d8d", + url: "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/869f193161a1d03460d901ad827483871d74f692/firmware-2.7.26/firmware.bin", + sha256: "b".repeat(64), + byteCount: 2_213_168, + }, + ], +}; + +describe("event firmware OTA contract", () => { + it("signs the exact payload bytes with Ed25519", () => { + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const envelope = signEventFirmwareOTAContract( + contract, + "event-release-2026", + privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + ); + + const payload = Buffer.from(envelope.payload, "base64"); + assert.deepEqual(JSON.parse(payload.toString()), contract); + assert.equal( + verifySignature( + null, + payload, + publicKey, + Buffer.from(envelope.signature, "base64"), + ), + true, + ); + }); + + it("selects contracts by normalized edition name", () => { + assert.equal(findEventFirmwareOTAContract([contract], "defcon"), contract); + assert.equal(findEventFirmwareOTAContract([contract], "../DEFCON"), null); + }); + + it("rejects mutable artifact URLs", () => { + const mutable = { + ...contract, + artifacts: [ + { + ...artifact, + url: "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/event/defcon34/firmware.bin", + }, + ], + }; + assert.throws(() => validateEventFirmwareOTAContract(mutable)); + }); + + it("rejects duplicate exact targets", () => { + const duplicate = { + ...contract, + artifacts: [artifact, artifact], + }; + assert.throws(() => validateEventFirmwareOTAContract(duplicate)); + }); + + it("rejects timestamps that the Apple decoder cannot consume", () => { + const fractionalTimestamp = { + ...contract, + issuedAt: "2026-07-29T00:00:00.000Z", + }; + assert.throws(() => validateEventFirmwareOTAContract(fractionalTimestamp)); + }); + + it("rejects malformed signing key identifiers", () => { + const { privateKey } = generateKeyPairSync("ed25519"); + assert.throws(() => + signEventFirmwareOTAContract( + contract, + "../release-key", + privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + ), + ); + }); + + it("rejects incompatible architecture metadata", () => { + const incompatible = { + ...contract, + artifacts: [ + { + ...artifact, + format: "otaZip" as const, + partitionRole: null, + partitionScheme: null, + dfuProtocol: "nordic-legacy", + minimumBootloaderVersion: "0.6.1", + }, + ], + }; + assert.throws(() => validateEventFirmwareOTAContract(incompatible)); + }); +}); From 1657bfdea4b05b2126a9a6c8b74ba6e54f236a79 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:41:30 -0700 Subject: [PATCH 2/2] fix(events): tighten OTA contract validation --- package.json | 2 +- src/lib/eventFirmwareOTA.ts | 53 +++++++++++++++++++++++++++------- src/routes/eventFirmwareOTA.ts | 16 ++++++---- tests/eventFirmwareOTA.test.ts | 28 ++++++++++++++++++ 4 files changed, 82 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 35c1a90..82f54b6 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev": "tsx src/index.ts", "start": "prisma migrate deploy && node dist/index.js", "build": "prisma generate && tsc", - "test": "tsx --test tests/**/*.test.ts" + "test": "tsx --test \"tests/**/*.test.ts\"" }, "dependencies": { "@buf/meshtastic_api.bufbuild_es": "1.7.2-20240110092216-3a147af14302.1", diff --git a/src/lib/eventFirmwareOTA.ts b/src/lib/eventFirmwareOTA.ts index a9d1637..0e186ab 100644 --- a/src/lib/eventFirmwareOTA.ts +++ b/src/lib/eventFirmwareOTA.ts @@ -4,7 +4,7 @@ import { readFileSync } from "node:fs"; const DATA_PATH = new URL("../../data/eventFirmwareOTA.json", import.meta.url); const EDITION_RE = /^[A-Z][A-Z0-9_]{0,63}$/; const IDENTIFIER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; -const SHA256_RE = /^[0-9a-f]{64}$/i; +const SHA256_RE = /^[0-9a-f]{64}$/; const VERSION_RE = /^\d+\.\d+\.\d+(?:\.[0-9A-Za-z_-]+)?$/; const ISO8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; const IMMUTABLE_RAW_GITHUB_RE = @@ -49,7 +49,7 @@ export interface EventFirmwareOTAContract { standardArtifacts: EventFirmwareOTAArtifact[]; } -interface EventFirmwareOTAData { +export interface EventFirmwareOTAData { version: number; contracts: EventFirmwareOTAContract[]; } @@ -61,7 +61,11 @@ const fail = (message: string): never => { }; const validateArtifact = (artifact: EventFirmwareOTAArtifact): void => { - if (!artifact.pioEnv || !Number.isSafeInteger(artifact.hwModel)) { + if ( + !artifact.pioEnv || + !Number.isSafeInteger(artifact.hwModel) || + artifact.hwModel < 0 + ) { fail("artifact target is incomplete"); } if ( @@ -144,6 +148,15 @@ const validateArtifactSet = ( } }; +const isExactISO8601Timestamp = (value: string): boolean => { + if (!ISO8601_RE.test(value)) return false; + const date = new Date(value); + return ( + Number.isFinite(date.getTime()) && + date.toISOString().replace(".000Z", "Z") === value + ); +}; + export const validateEventFirmwareOTAContract = ( contract: EventFirmwareOTAContract, ): void => { @@ -157,8 +170,8 @@ export const validateEventFirmwareOTAContract = ( } if ( - !ISO8601_RE.test(contract.issuedAt) || - !ISO8601_RE.test(contract.expiresAt) + !isExactISO8601Timestamp(contract.issuedAt) || + !isExactISO8601Timestamp(contract.expiresAt) ) { fail("validity timestamps must use UTC ISO-8601 seconds"); } @@ -182,6 +195,29 @@ export const validateEventFirmwareOTAContract = ( validateArtifactSet("artifacts", contract.artifacts); validateArtifactSet("standardArtifacts", contract.standardArtifacts); + if ( + contract.artifacts.some((artifact) => artifact.version !== contract.version) + ) { + fail("event artifact version does not match the release"); + } +}; + +export const validateEventFirmwareOTAData = ( + data: EventFirmwareOTAData, +): void => { + if (data.version !== 1 || !Array.isArray(data.contracts)) { + fail("data envelope is invalid"); + } + const editions = new Set(); + const releaseIds = new Set(); + for (const contract of data.contracts) { + validateEventFirmwareOTAContract(contract); + if (editions.has(contract.edition) || releaseIds.has(contract.releaseId)) { + fail("data contains a duplicate edition or release identifier"); + } + editions.add(contract.edition); + releaseIds.add(contract.releaseId); + } }; const getEventFirmwareOTAData = (): EventFirmwareOTAData => { @@ -189,12 +225,7 @@ const getEventFirmwareOTAData = (): EventFirmwareOTAData => { cached = JSON.parse( readFileSync(DATA_PATH, "utf8"), ) as EventFirmwareOTAData; - if (cached.version !== 1 || !Array.isArray(cached.contracts)) { - fail("data envelope is invalid"); - } - for (const contract of cached.contracts) { - validateEventFirmwareOTAContract(contract); - } + validateEventFirmwareOTAData(cached); } return cached; }; diff --git a/src/routes/eventFirmwareOTA.ts b/src/routes/eventFirmwareOTA.ts index 733eb4c..b80efc7 100644 --- a/src/routes/eventFirmwareOTA.ts +++ b/src/routes/eventFirmwareOTA.ts @@ -1,12 +1,20 @@ import { app } from "../index.js"; import { + type EventFirmwareOTAContract, getEventFirmwareOTAContract, signEventFirmwareOTAContract, } from "../lib/eventFirmwareOTA.js"; export const EventFirmwareOTARoutes = () => app.get("resource/eventFirmware/:edition/ota", (req, res) => { - const contract = getEventFirmwareOTAContract(req.params.edition ?? ""); + res.setHeader("Cache-Control", "no-store"); + let contract: EventFirmwareOTAContract | null; + try { + contract = getEventFirmwareOTAContract(req.params.edition ?? ""); + } catch (error) { + console.error("eventFirmwareOTA data", error); + return res.sendStatus(502); + } if (!contract) return res.sendStatus(404); const keyId = process.env.EVENT_FIRMWARE_SIGNING_KEY_ID; @@ -15,14 +23,12 @@ export const EventFirmwareOTARoutes = () => console.error("Event firmware OTA signing key is not configured"); return res.sendStatus(503); } - try { - res.setHeader("Cache-Control", "no-store"); return res.json( signEventFirmwareOTAContract(contract, keyId, privateKey), ); } catch (error) { - console.error("eventFirmwareOTA", error); - return res.sendStatus(502); + console.error("eventFirmwareOTA signing", error); + return res.sendStatus(503); } }); diff --git a/tests/eventFirmwareOTA.test.ts b/tests/eventFirmwareOTA.test.ts index a6e78f9..967db3e 100644 --- a/tests/eventFirmwareOTA.test.ts +++ b/tests/eventFirmwareOTA.test.ts @@ -6,6 +6,7 @@ import { findEventFirmwareOTAContract, signEventFirmwareOTAContract, validateEventFirmwareOTAContract, + validateEventFirmwareOTAData, } from "../src/lib/eventFirmwareOTA.js"; const artifact = { @@ -53,6 +54,8 @@ describe("event firmware OTA contract", () => { ); const payload = Buffer.from(envelope.payload, "base64"); + assert.equal(envelope.keyId, "event-release-2026"); + assert.equal(payload.toString("utf8"), JSON.stringify(contract)); assert.deepEqual(JSON.parse(payload.toString()), contract); assert.equal( verifySignature( @@ -91,6 +94,23 @@ describe("event firmware OTA contract", () => { assert.throws(() => validateEventFirmwareOTAContract(duplicate)); }); + it("rejects event artifacts from another release version", () => { + const mismatched = { + ...contract, + artifacts: [{ ...artifact, version: "2.8.0.other" }], + }; + assert.throws(() => validateEventFirmwareOTAContract(mismatched)); + }); + + it("rejects duplicate editions and release identifiers", () => { + assert.throws(() => + validateEventFirmwareOTAData({ + version: 1, + contracts: [contract, { ...contract, releaseId: "another-release" }], + }), + ); + }); + it("rejects timestamps that the Apple decoder cannot consume", () => { const fractionalTimestamp = { ...contract, @@ -99,6 +119,14 @@ describe("event firmware OTA contract", () => { assert.throws(() => validateEventFirmwareOTAContract(fractionalTimestamp)); }); + it("rejects normalized invalid calendar dates", () => { + const invalidCalendarDate = { + ...contract, + issuedAt: "2026-02-31T00:00:00Z", + }; + assert.throws(() => validateEventFirmwareOTAContract(invalidCalendarDate)); + }); + it("rejects malformed signing key identifiers", () => { const { privateKey } = generateKeyPairSync("ed25519"); assert.throws(() =>