diff --git a/docs/apk-signature.md b/docs/apk-signature.md index d6150d25c6..1b7182d2e9 100644 --- a/docs/apk-signature.md +++ b/docs/apk-signature.md @@ -264,6 +264,13 @@ file SHA-256 and byte count. These facts describe the acquired file; they do not establish any decoded signing facts. Failed cleanup remains visible until the provider releases the descriptor on a cleanup retry. +A classic EOCD's disk declarations are checked before using its directory offset +as a span in the selected file. Nonzero current or central-directory disk numbers +are unsupported; differing per-disk and total entry counts in a single-disk +archive are malformed. This also applies to exactly 65,535 classic entries when +no Zip64 locator is present. A refused layout does not establish that signing +schemes are absent; select a complete single-disk archive for inspection. + A file that is not a ZIP is refused as an unsupported target. A damaged ZIP (local headers but no end-of-central-directory), a central directory that cannot be walked or whose declared record count does not account for all of @@ -275,7 +282,7 @@ input errors, not evidence of an unsupported format. Coherent multi-disk locator metadata and 64-bit offsets/counts/sizes beyond the reader's safe integer range remain unsupported. The locator offset is disk-relative, so multi-disk metadata is checked before applying local file bounds. This follows -the record fields in [PKWARE APPNOTE 6.3.10, sections 4.3.14–4.3.15](https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT); +the record fields in [PKWARE APPNOTE 6.3.10, sections 4.3.14–4.3.16 and 4.4.19–4.4.22](https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT); it does not establish full validation of Zip64 versions or extensible data. Signer framing includes signature records, the public-key field and matching signed/outer v3 SDK ranges. Suffix bytes beyond known diff --git a/src/apkSignature/apkSignatureFailures.ts b/src/apkSignature/apkSignatureFailures.ts index 4e29403e84..82e08fe916 100644 --- a/src/apkSignature/apkSignatureFailures.ts +++ b/src/apkSignature/apkSignatureFailures.ts @@ -54,6 +54,13 @@ export const mapApkBlockError = ( path, `This archive uses a Zip64 structure this reader does not decode (${error.detail}).`, ); + case "multi-disk-unsupported": + return new AnalysisUnsupportedTargetError( + OPERATION, + path, + `The selected archive declares an unsupported multi-disk ZIP layout (${error.detail}).`, + { remediationAction: "Select a complete single-disk APK/ZIP archive." }, + ); case "malformed-zip": return inputError( `Damaged ZIP/APK at byte ${String(error.offset)}: ${error.detail}.`, diff --git a/src/domain/apkSignature/apkSigningBlock.test.ts b/src/domain/apkSignature/apkSigningBlock.test.ts index 458b698c68..1a2f397e00 100644 --- a/src/domain/apkSignature/apkSigningBlock.test.ts +++ b/src/domain/apkSignature/apkSigningBlock.test.ts @@ -360,3 +360,62 @@ describe("EOCD that cannot describe the archive", () => { expect(tail.error.kind).toBe("malformed-zip"); }); }); + +describe("classic ZIP disk declarations", () => { + it.each([ + [1, 0], + [0, 1], + [1, 1], + ])( + "refuses disk %s and directory disk %s before selecting a local span", + (disk, directoryDisk) => { + const bytes = apkWithBlock([]); + const eocd = bytes.length - 22; + const view = new DataView(bytes.buffer); + view.setUint16(eocd + 4, disk, true); + view.setUint16(eocd + 6, directoryDisk, true); + expect(parseZipTail(bytes)).toMatchObject({ + ok: false, + error: { kind: "multi-disk-unsupported" }, + }); + }, + ); + + it.each([0, 2])( + "refuses a single-disk record declaring %s of its one entry", + (entriesOnDisk) => { + const bytes = apkWithBlock([]); + const eocd = bytes.length - 22; + new DataView(bytes.buffer).setUint16(eocd + 8, entriesOnDisk, true); + expect(parseZipTail(bytes)).toMatchObject({ + ok: false, + error: { kind: "malformed-zip", offset: eocd + 8 }, + }); + }, + ); + + it("checks disk ownership in the count-saturated classic fallback", () => { + const bytes = apkWithBlock([]); + const eocd = bytes.length - 22; + const view = new DataView(bytes.buffer); + view.setUint16(eocd + 4, 1, true); + view.setUint16(eocd + 8, 0xffff, true); + view.setUint16(eocd + 10, 0xffff, true); + expect(parseZipTail(bytes)).toMatchObject({ + ok: false, + error: { kind: "multi-disk-unsupported" }, + }); + }); + + it("checks per-disk counts in the count-saturated classic fallback", () => { + const bytes = apkWithBlock([]); + const eocd = bytes.length - 22; + const view = new DataView(bytes.buffer); + view.setUint16(eocd + 8, 0xfffe, true); + view.setUint16(eocd + 10, 0xffff, true); + expect(parseZipTail(bytes)).toMatchObject({ + ok: false, + error: { kind: "malformed-zip", offset: eocd + 8 }, + }); + }); +}); diff --git a/src/domain/apkSignature/apkSigningBlock.ts b/src/domain/apkSignature/apkSigningBlock.ts index d83f708d96..f3ecef62a2 100644 --- a/src/domain/apkSignature/apkSigningBlock.ts +++ b/src/domain/apkSignature/apkSigningBlock.ts @@ -42,6 +42,7 @@ export const APK_BLOCK_ID = { export type ApkSigningBlockError = | { readonly kind: "not-a-zip"; readonly detail: string } | { readonly kind: "zip64-unsupported"; readonly detail: string } + | { readonly kind: "multi-disk-unsupported"; readonly detail: string } | { /** A ZIP whose end-of-central-directory is damaged or inconsistent. */ readonly kind: "malformed-zip"; @@ -101,14 +102,41 @@ const hasLocalFileHeader = (bytes: Uint8Array): boolean => bytes[2] === 0x03 && bytes[3] === 0x04; +interface CentralDirectory { + readonly offset: number; + readonly size: number; + readonly entries: number; + readonly end: number; +} + +/** Admit a classic directory only when its offset describes this selected disk. */ +const classicDirectory = ( + eocd: ZipEndOfCentralDirectory, +): Result => { + if (eocd.disk !== 0 || eocd.directoryDisk !== 0) + return err({ + kind: "multi-disk-unsupported", + detail: `classic EOCD names disk ${String(eocd.disk)} and central-directory disk ${String(eocd.directoryDisk)}`, + }); + if (eocd.entriesOnDisk !== eocd.totalEntries) + return err({ + kind: "malformed-zip", + offset: eocd.offset + 8, + detail: "single-disk ZIP entry counts disagree", + }); + return ok({ + offset: eocd.directoryOffset, + size: eocd.directorySize, + entries: eocd.totalEntries, + end: eocd.offset, + }); +}; + /** Resolve the central-directory offset, following a Zip64 EOCD when present. */ const centralDirectoryOffset = ( bytes: Uint8Array, eocd: ZipEndOfCentralDirectory, -): Result< - { offset: number; size: number; entries: number; end: number }, - ApkSigningBlockError -> => { +): Result => { const dv = view(bytes); const { offset: eocdOffset, @@ -120,7 +148,7 @@ const centralDirectoryOffset = ( offset === ZIP64_SENTINEL || size === ZIP64_SENTINEL || entries === ZIP64_ENTRIES_SENTINEL; - if (!saturated) return ok({ offset, size, entries, end: eocdOffset }); + if (!saturated) return classicDirectory(eocd); // Zip64: a locator precedes the EOCD, pointing at the Zip64 EOCD record. const locator = eocdOffset - 20; if ( @@ -130,7 +158,7 @@ const centralDirectoryOffset = ( // Exactly 65,535 entries saturates the count without Zip64; that archive // is still described completely by its 32-bit EOCD fields. if (offset !== ZIP64_SENTINEL && size !== ZIP64_SENTINEL) - return ok({ offset, size, entries, end: eocdOffset }); + return classicDirectory(eocd); return err({ kind: "malformed-zip", offset: eocdOffset, diff --git a/tests/acceptance/apkZip64FramingCli.test.ts b/tests/acceptance/apkZip64FramingCli.test.ts index 9c8faecb4c..4ed6eb5888 100644 --- a/tests/acceptance/apkZip64FramingCli.test.ts +++ b/tests/acceptance/apkZip64FramingCli.test.ts @@ -7,6 +7,7 @@ import { analysisErrorProjectionSchema } from "../../src/contracts/errorSchemas. import { apkSignatureResultSchema } from "../../src/domain/apkSignature/apkSignatureReport.js"; import { parseEvidence } from "../../src/domain/evidence.js"; import { zip64Tail } from "../fixtures/apkSignature/zip64Tail.js"; +import { zipWith } from "../fixtures/apkSignature/v1SignatureBlock.js"; import { parseMcpToolError } from "../fixtures/mcpToolError.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { cliTest, type TestCli } from "../support/cli/cliFixture.js"; @@ -29,7 +30,7 @@ const inspectBoth = async (cli: TestCli, path: string) => { return { output, response }; }; -const expectUnsignedZip64 = async ( +const expectUnsignedArchive = async ( cli: TestCli, path: string, bytes: Uint8Array, @@ -72,6 +73,8 @@ const expectRefusal = async ( expect(output.exitCode, output.stderr).toBe(1); const cliError = analysisErrorProjectionSchema.parse(output.json); expect(cliError).toMatchObject(expected); + expect(output.json).not.toHaveProperty("normalized_result"); + expect(response).not.toHaveProperty("structuredContent.normalized_result"); const mcpError = parseMcpToolError(response).error; expect(mcpError).toMatchObject(expected); expect(mcpError.message).toBe(cliError.message); @@ -85,7 +88,7 @@ cliTest( const path = join(root, "selected.apk"); const fixture = await zip64Tail(); const { recordOffset, locatorOffset, eocdOffset } = fixture; - await expectUnsignedZip64(cli, path, fixture.bytes); + await expectUnsignedArchive(cli, path, fixture.bytes); const cases = [ { at: locatorOffset, @@ -153,7 +156,53 @@ cliTest( }); } // Restore the exact original bytes at the same caller-selected path. - await expectUnsignedZip64(cli, path, fixture.bytes); + await expectUnsignedArchive(cli, path, fixture.bytes); + }, +); + +cliTest( + "refuses classic disk declarations before returning signing Evidence through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apk-classic-disks-"); + const path = join(root, "selected.apk"); + const original = await zipWith( + [ + { name: "classes.dex", data: "inert", stored: true }, + { name: "AndroidManifest.xml", data: "", stored: true }, + ], + { dataDescriptor: false }, + ); + await expectUnsignedArchive(cli, path, original); + const eocd = original.length - 22; + for (const at of [4, 6]) { + const bytes = Uint8Array.from(original); + new DataView(bytes.buffer).setUint16(eocd + at, 1, true); + // Only disk declarations change; these are not genuine split APK sets. + await expectRefusal(cli, path, bytes, { + code: "unsupported_target", + category: "unsupported_target", + message: expect.stringContaining("multi-disk ZIP"), + details: { path }, + }); + } + for (const entriesOnDisk of [0, 3]) { + const bytes = Uint8Array.from(original); + new DataView(bytes.buffer).setUint16(eocd + 8, entriesOnDisk, true); + await expectRefusal(cli, path, bytes, { + code: "invalid_request", + category: "invalid_input", + details: { + issues: [ + { + path: ["path"], + reason: "invalid_value", + message: expect.stringContaining(`byte ${String(eocd + 8)}`), + }, + ], + }, + }); + } + await expectUnsignedArchive(cli, path, original); }, );