Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ DATABASE_URL=
MQTT_URL=
MQTT_USERNAME=
MQTT_PASSWORD=
MQTT_ROOT_TOPIC=
MQTT_ROOT_TOPIC=
EVENT_FIRMWARE_SIGNING_KEY_ID=
EVENT_FIRMWARE_SIGNING_PRIVATE_KEY_PEM=
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,6 @@ jobs:

- name: Build Package
run: pnpm build

- name: Run Tests
run: pnpm test
47 changes: 47 additions & 0 deletions data/eventFirmwareOTA.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
]
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import {
DeviceLinksRoutes,
EventFirmwareIconRoutes,
EventFirmwareOTARoutes,
EventFirmwareRoutes,
FirmwareRoutes,
GithubRoutes,
Expand Down Expand Up @@ -74,7 +75,7 @@
router.service(GatewayService, new Gateway());
},
connect: true,
// @ts-ignore

Check warning on line 78 in src/index.ts

View workflow job for this annotation

GitHub Actions / quality

lint/suspicious/noTsIgnore

Unsafe use of the @ts-ignore directive found in this comment.
})(req, res, next);
});

Expand All @@ -86,8 +87,9 @@
ResourceRoutes();
DeviceLinksRoutes();
EventFirmwareRoutes();
EventFirmwareOTARoutes();
EventFirmwareIconRoutes();
UpdaterRoutes();
MqttRoutes();

app.listen(Number.parseInt(process.env.PORT ?? "4000"));

Check notice on line 95 in src/index.ts

View workflow job for this annotation

GitHub Actions / quality

lint/correctness/useParseIntRadix

Missing radix parameter
267 changes: 267 additions & 0 deletions src/lib/eventFirmwareOTA.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
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}$/;
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[];
}

export 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) ||
artifact.hwModel < 0
) {
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<string>();
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);
}
};

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 => {
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 (
!isExactISO8601Timestamp(contract.issuedAt) ||
!isExactISO8601Timestamp(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");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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);
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<string>();
const releaseIds = new Set<string>();
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 => {
if (!cached) {
cached = JSON.parse(
readFileSync(DATA_PATH, "utf8"),
) as EventFirmwareOTAData;
validateEventFirmwareOTAData(cached);
}
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"),
};
};
34 changes: 34 additions & 0 deletions src/routes/eventFirmwareOTA.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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) => {
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;
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 {
return res.json(
signEventFirmwareOTAContract(contract, keyId, privateKey),
);
} catch (error) {
console.error("eventFirmwareOTA signing", error);
return res.sendStatus(503);
}
});
Loading
Loading