From 3ef0e1b4944c2f0abbe81bb3ae4c385aaa22a709 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 10 Apr 2026 19:47:21 +0800 Subject: [PATCH] fix(ota): verify mirrored asset hashes --- apps/ota/src/__tests__/sync.test.ts | 56 ++++++++++++++++++++++++----- apps/ota/src/lib/archive.ts | 24 ++++++++++++- 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/apps/ota/src/__tests__/sync.test.ts b/apps/ota/src/__tests__/sync.test.ts index befc6182e..26e73737f 100644 --- a/apps/ota/src/__tests__/sync.test.ts +++ b/apps/ota/src/__tests__/sync.test.ts @@ -205,14 +205,15 @@ describe("listPublishedOtaReleases", () => { describe("extractMirroredFiles", () => { it("extracts only referenced files from a tar archive", async () => { + const iosBundle = textEncoder.encode("console.log('ios')") const archiveBuffer = await createTarArchive([ { name: "bundles/ios-main.js", - body: "console.log('ios')", + body: iosBundle, }, { name: "bundles/unused.js", - body: "console.log('unused')", + body: textEncoder.encode("console.log('unused')"), }, ]) @@ -223,7 +224,7 @@ describe("extractMirroredFiles", () => { ios: { launchAsset: { path: "bundles/ios-main.js", - sha256: "a".repeat(64), + sha256: await sha256Hex(iosBundle), contentType: "application/javascript", }, assets: [], @@ -236,7 +237,7 @@ describe("extractMirroredFiles", () => { expect(files).toEqual([ { key: "mobile/production/0.4.1/0.4.2/ios/bundles/ios-main.js", - body: textEncoder.encode("console.log('ios')"), + body: iosBundle, contentType: "application/javascript", }, ]) @@ -246,7 +247,7 @@ describe("extractMirroredFiles", () => { const archiveBuffer = await createTarArchive([ { name: "bundles/unused.js", - body: "console.log('unused')", + body: textEncoder.encode("console.log('unused')"), }, ]) @@ -269,6 +270,34 @@ describe("extractMirroredFiles", () => { }), ).rejects.toThrow('Archive is missing referenced file "bundles/ios-main.js"') }) + + it("throws when a referenced archive file hash does not match metadata", async () => { + const archiveBuffer = await createTarArchive([ + { + name: "bundles/ios-main.js", + body: textEncoder.encode("console.log('tampered')"), + }, + ]) + + await expect( + extractMirroredFiles({ + release: { + ...baseRelease, + platforms: { + ios: { + launchAsset: { + path: "bundles/ios-main.js", + sha256: await sha256Hex(textEncoder.encode("console.log('ios')")), + contentType: "application/javascript", + }, + assets: [], + }, + }, + }, + archiveBuffer, + }), + ).rejects.toThrow('Archive file "bundles/ios-main.js" hash mismatch') + }) }) describe("putMirroredFiles", () => { @@ -371,7 +400,7 @@ describe("mirrorReleaseToStorage", () => { async function createTarArchive( entries: Array<{ name: string - body: string + body: Uint8Array }>, ) { const pack = tar.pack() @@ -389,11 +418,10 @@ async function createTarArchive( for (const entry of entries) { await new Promise((resolve, reject) => { - const body = textEncoder.encode(entry.body) const tarEntry = pack.entry( { name: entry.name, - size: body.byteLength, + size: entry.body.byteLength, }, (error) => { if (error) { @@ -406,7 +434,7 @@ async function createTarArchive( ) tarEntry.on("error", reject) - tarEntry.end(body) + tarEntry.end(entry.body) }) } @@ -427,3 +455,13 @@ function concatenateChunks(chunks: readonly Uint8Array[]) { return output } + +async function sha256Hex(data: Uint8Array) { + const digest = await crypto.subtle.digest("SHA-256", toDigestInput(data)) + + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("") +} + +function toDigestInput(data: Uint8Array) { + return new Uint8Array(data) +} diff --git a/apps/ota/src/lib/archive.ts b/apps/ota/src/lib/archive.ts index 5efdc6ba5..6bf74eb65 100644 --- a/apps/ota/src/lib/archive.ts +++ b/apps/ota/src/lib/archive.ts @@ -12,6 +12,7 @@ interface MirroredFileRequest { archivePath: string key: string contentType: string + sha256: string body?: Uint8Array } @@ -76,11 +77,21 @@ export async function extractMirroredFiles(input: { stream.on("error", (error) => { rejectOnce(toError(error)) }) - stream.on("end", () => { + stream.on("end", async () => { if (matchingRequests) { const body = concatenateChunks(chunks) + const bodySha256 = await sha256Hex(body) for (const request of matchingRequests) { + if (request.sha256 !== bodySha256) { + rejectOnce( + new Error( + `Archive file "${archivePath}" hash mismatch: expected ${request.sha256} but received ${bodySha256}`, + ), + ) + return + } + request.body = body } @@ -151,6 +162,7 @@ function createMirroredFileRequests(release: OtaRelease): MirroredFileRequest[] archivePath, key: buildMirroredAssetKey(release, platform, archivePath), contentType: asset.contentType, + sha256: asset.sha256, }) } } @@ -209,3 +221,13 @@ function once void>(callback: T): T { function toError(error: unknown) { return error instanceof Error ? error : new Error(String(error)) } + +async function sha256Hex(data: Uint8Array) { + const digest = await crypto.subtle.digest("SHA-256", toDigestInput(data)) + + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("") +} + +function toDigestInput(data: Uint8Array) { + return new Uint8Array(data) +}