diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 6e293a4669..adf475e0f9 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -166,6 +166,14 @@ explicit path or endpoint and do not need it. Select JavaScript glue and candidate paths explicitly and preserve URL/path ambiguity. No implicit fetch or target execution; WAT is not original source. See the [WASM guide](https://github.com/morluto/rea/blob/main/docs/wasm-artifacts.md). +- Explicit local Android APK (or ZIP/AAB) signing: `inspect_apk_signature`. + Prefer its summary or selected signer/digest/lineage page; retain complete + Evidence and use `inspect_analysis_view` kind `apk-signature` for follow-ups. + Reports which schemes are present (v1 JAR, v2, v3, v3.1), each signer's + certificate SHA-256 fingerprint (v1 included, from its PKCS#7 block), v3 key-rotation lineage, source stamp, and + whether each signed digest still matches the bytes (self-consistency). This + is identity and integrity, not a trust or authenticity verdict. + See the [APK signature guide](https://github.com/morluto/rea/blob/main/docs/apk-signature.md). - Explicit local EVM bytecode carrier: `inspect_evm_interface` with caller-selected `raw` or `hex` encoding. The owned worker deadline is `timeout_ms` (default 30000 ms, range 1..2147483647, next call). It does not raise the fixed byte, diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 4ab8155904..c06025d613 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -195,6 +195,30 @@ and tool profile. Text offsets count UTF-16 code units; follow `next_offset`. The complete source remains retained after an oversized MCP reply. No target execution or network acquisition is implied, and WAT is decoded representation. +## Offline APK signatures + +Use `inspect_apk_signature` for a selected local APK/ZIP/AAB. Default MCP summaries +retain complete Evidence. Select `view.collection: signers/digests/lineage`, +with scheme or ID batches, fields and paging; use `inspect_analysis_view` kind +`apk-signature` for retained reads after deletion. IDs retain artifact/scheme +scope and signer links; `fields: []` keeps identity links. Empty pages preserve +partial decoding reasons and unknown authenticity. CLI compact delivery needs +`--evidence_output` to preserve the complete parent. +It reads the APK +Signing Block (v2/v3/v3.1) and v1 `META-INF` signing without executing anything, +and reports each signer's certificate facts (for v1, the certificate the PKCS#7 +block's SignerInfo names), the declared v3 rotation lineage, +the declared source-stamp certificate and, for SHA-256/SHA-512 digests, whether +the declared content digest matches the current bytes. That match is not a +signature verification: anyone who changes the bytes can recompute the digest. +`posture` summarises v1-only signing (which cannot tell a v1-only APK from one +whose v2/v3 block was removed), observed signer agreement across schemes, +whether the v1 signer equals the block-scheme signer (`false` is notable), +whether every differing signer appears in a declared (unverified) rotation +lineage, and the strongest scheme. +It never claims authenticity: no signature or lineage is cryptographically +verified. + ## Native targets After `open_binary`, use focused search, procedure, or function tools directly. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21f317fdea..1588b642ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,7 @@ jobs: browser: ${{ steps.plan.outputs.browser }} binary_layout: ${{ steps.plan.outputs.binary_layout }} evm: ${{ steps.plan.outputs.evm }} + apk_signature: ${{ steps.plan.outputs.apk_signature }} javascript_recovery: ${{ steps.plan.outputs.javascript_recovery }} recorded_crash: ${{ steps.plan.outputs.recorded_crash }} web_network: ${{ steps.plan.outputs.web_network }} @@ -384,6 +385,7 @@ jobs: needs.changes.outputs.browser == 'true' || needs.changes.outputs.binary_layout == 'true' || needs.changes.outputs.evm == 'true' || + needs.changes.outputs.apk_signature == 'true' || needs.changes.outputs.javascript_recovery == 'true' || needs.changes.outputs.recorded_crash == 'true' || needs.changes.outputs.web_network == 'true' || @@ -1003,6 +1005,16 @@ jobs: build_cache_artifact: rea-build-cache remote_cache_write: ${{ inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) }} + apk_signature: + needs: [changes, static, build] + if: needs.changes.outputs.apk_signature == 'true' + secrets: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + uses: ./.github/workflows/real-apk-signature.yml + with: + build_cache_artifact: rea-build-cache + remote_cache_write: ${{ inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) }} + javascript_recovery: needs: [changes, static, build] if: needs.changes.outputs.javascript_recovery == 'true' @@ -1103,6 +1115,7 @@ jobs: browser, binary_layout, evm, + apk_signature, javascript_recovery, recorded_crash, web_network, diff --git a/.github/workflows/real-apk-signature.yml b/.github/workflows/real-apk-signature.yml new file mode 100644 index 0000000000..492beaa67c --- /dev/null +++ b/.github/workflows/real-apk-signature.yml @@ -0,0 +1,97 @@ +name: Real APK signature inspection + +on: + workflow_call: + inputs: + build_cache_artifact: + description: Portable build cache artifact from the calling CI run. + required: false + type: string + default: "" + remote_cache_write: + description: Trusted release qualification may publish the remote cache. + required: false + type: boolean + default: false + secrets: + TURBO_TOKEN: + description: Remote cache token. Only trusted callers pass it. + required: false + workflow_dispatch: + +permissions: + contents: read + +env: + HUSKY: "0" + # A called workflow sees the caller event, so workflow_call is not an event + # name here. Writes when remote_cache_write is set, or on the default branch + # for push, schedule, and manual dispatch. Open pull requests stay empty-token + # and use the local cache only. + TURBO_TOKEN: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && secrets.TURBO_TOKEN || '' }}" + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + TURBO_CACHE: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && 'local:rw,remote:rw' || 'local:rw' }}" + NODE_OPTIONS: "--max-old-space-size=768" + RAYON_NUM_THREADS: "1" + GOMAXPROCS: "2" + UV_THREADPOOL_SIZE: "2" + JAVA_TOOL_OPTIONS: "-Xmx512m -XX:ActiveProcessorCount=2" + +jobs: + real-apk-signature: + runs-on: ubuntu-latest + timeout-minutes: 12 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc + - name: Cache npm package downloads + id: npm-cache + uses: ./.github/actions/npm-cache + - run: npm ci + - uses: ./.github/actions/portable-build-cache + with: + build_cache_artifact: ${{ inputs.build_cache_artifact }} + - name: Prepare pinned SDK oracle without installing host software + shell: bash + run: | + python3 - <<'PYTHON' + import hashlib, os, urllib.request, zipfile + from pathlib import Path + root = Path(os.environ["RUNNER_TEMP"]) / "rea-apk-signature-sdk" + packages = [ + ("build-tools_r36.1_linux.zip", "a7b5889e4a79fcf3b0976bef40d401f4240fb1eed891d9d91169da1111e11d78", 64195204, "build-tools/36.1.0", {"apksigner", "aapt2", "lib/apksigner.jar", "source.properties"}), + ("platform-36_r02.zip", "37607369a28c5b640b3a7998868d45898ebcb777565a0e85f9acf36f29631d2e", 65878410, "platforms/android-36", {"android.jar", "source.properties"}), + ] + for name, expected, expected_size, directory, selected in packages: + archive = root / name + archive.parent.mkdir(parents=True, exist_ok=True) + digest = hashlib.sha256() + total = 0 + with urllib.request.urlopen("https://dl.google.com/android/repository/" + name, timeout=60) as response, archive.open("wb") as output: + while chunk := response.read(1024 * 1024): + total += len(chunk) + if total > expected_size: + raise RuntimeError("SDK archive exceeds the pinned package size budget") + digest.update(chunk) + output.write(chunk) + if total != expected_size or digest.hexdigest() != expected: + raise RuntimeError("SDK oracle archive digest mismatch") + extracted = set() + with zipfile.ZipFile(archive) as source: + for entry in source.infolist(): + relative = entry.filename.partition("/")[2] + if relative not in selected: + continue + target = root / directory / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(source.read(entry)) + target.chmod((entry.external_attr >> 16) & 0o777 or 0o644) + extracted.add(relative) + if extracted != selected: + raise RuntimeError("Pinned SDK package is missing required oracle files") + with Path(os.environ["GITHUB_ENV"]).open("a") as environment: + environment.write("ANDROID_HOME=" + str(root) + "\n") + PYTHON + - run: npm run verify:apk-signature -- --require-tools diff --git a/README.md b/README.md index 465330a742..d590878a68 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,7 @@ Additional tools and host support depend on the target: | Saved network captures | Requests, responses, exposed payloads and source locations | HAR or Fiddler Everywhere SAZ; mitmdump on Linux for native mitmproxy; [capture guide](docs/web-network-captures.md) | | .NET assemblies | Metadata, CIL instructions, declared native dependencies and build comparisons | None (static only); [managed-code guide](docs/managed-code-analysis.md) | | Android APKs | Manifest declarations, classes, decompiled methods and references | Headless JADX and a full JDK on Linux/macOS/Windows x64; [Android guide](docs/android-analysis.md) | +| APK signatures | v1/v2/v3/v3.1 signers, certificate fingerprints, key rotation and digest self-consistency | None (static only); `inspect_apk_signature` | | Android devices | Devices, packages, processes, logs, dumps, screen captures, and APK/file transfers | Caller-supplied adb (emulator or hardware); [ADB guide](docs/adb-device-analysis.md) | | JEB projects | Engine-backed project units, artifact digests and type/method pseudo-code | A caller-started JEB client serving MCP, selected with REA_JEB_MCP_URL; [JEB guide](docs/jeb-analysis.md) | | Android resources | Decoded manifest, string tables, locales and version facts | Caller-supplied Apktool launcher; [resource guide](docs/apktool-resource-analysis.md) | diff --git a/docs/apk-signature.md b/docs/apk-signature.md new file mode 100644 index 0000000000..ece167d0b1 --- /dev/null +++ b/docs/apk-signature.md @@ -0,0 +1,263 @@ +# APK signature schemes + +REA reads the signing of a selected local Android APK (or ZIP/AAB) statically, +so an analyst can answer **who signed this, under which schemes, and whether the +declared content digests match these bytes** before trusting or triaging it. The reader +is in-process and needs no external engine: it parses the ZIP tail and the APK +Signing Block, reads certificates with the platform X.509 parser, and +recomputes the content digest. No Android code executes, and no device or +`adb` is involved. + +CLI and MCP share one workflow and return artifact-bound Evidence inline. + +```sh +rea inspect-apk-signature /path/app.apk +``` + +## Selected facts and retained Evidence + +MCP defaults to a summary with scheme presence, signer certificate fingerprints, +row counts, posture and explicit coverage. `detail: "complete"` returns the full +report. A selected page can answer a narrower question in the acquisition call: + +```json +{ + "path": "/path/app.apk", + "view": { + "collection": "signers", + "schemes": ["v3"], + "fields": ["certificate_sha256"], + "limit": 32 + } +} +``` + +The returned `parent_evidence_id` identifies the retained complete report. +Subsequent reads use `inspect_analysis_view`, including after the APK is deleted: + +```json +{ + "source": { + "kind": "retained-evidence", + "evidence_id": "" + }, + "view": { + "kind": "apk-signature", + "collection": "digests", + "signer_ids": [""], + "fields": ["signature_algorithm_id", "algorithm", "matches_apk"], + "offset": 0, + "limit": 32 + } +} +``` + +Collections are `signers`, `digests` and declared `lineage` nodes. `ids`, +`signer_ids` and `schemes` filters intersect; omitted filters select all available +records and `[]` selects none. Omitted `fields` returns every collection column; +`fields: []` retains identity and relationship links. IDs include the complete +APK digest, scheme and source ordinal; child records retain `signer_id` and +`signer_index`, and declared certificates retain their raw-DER SHA-256 identity. +Lineage pages expose `previous_id` when selected. Certificate fingerprints can +be composed across versions without treating a declaration as verified identity. + +Page coverage describes examined declared records. Exhausting a page does not +establish certificate authenticity, platform acceptance, v1 entry coverage or a +verified rotation. Partial decoding and its reason remain visible even when a +lineage page contains no rows. A weak cache reuses validated immutable report +facts; each read checks its parent subject, and new file inspections read again. + +CLI defaults to complete delivery. A compact one-shot response requires a complete +Evidence export: + +```sh +rea inspect-apk-signature /path/app.apk --detail summary \ + --view '{"collection":"signers","fields":["certificate_sha256"]}' \ + --evidence_output /path/app-signing-evidence.json --json +``` + +The export contains the same complete parent used by MCP. Historical CLI pages +use `inspect-analysis-view` with an `evidence-file` source record. The CLI and MCP +share the application workflow, projection, retention and publication owners. + +## What it reports + +- **Schemes present:** v1 (JAR signing, from the central directory), and the + APK Signing Block's v2, v3 and v3.1. v1 follows apksig's selection: entries + under `META-INF/` at any depth ending in `.SF`, or in `.RSA`/`.DSA`/`.EC` + (case-sensitive). v1 is `present` only when a signature block has its + matching `.SF` file (`META-INF/x/CERT.RSA` pairs with `META-INF/x/CERT.SF`); + `signature_files` and `block_files` list every candidate either way. +- **Per v1 signer** (each block paired with its `.SF`): REA inflates the block + and reads its PKCS#7 `SignedData`. `certificate` is the certificate the + block's one `SignerInfo` names by `issuerAndSerialNumber` or + `subjectKeyIdentifier` (not by position), with the same facts and raw-DER + SHA-256 as a v2/v3 signer, so a v1-only APK can be fingerprinted and + clustered with v2/v3-signed ones. apksig resolves only + `issuerAndSerialNumber`, so its v1 verification fails for a block whose + `SignerInfo` uses `subjectKeyIdentifier`: REA still reports the declared + certificate (`identified_by: subject-key-identifier`), adds a limitation, + and treats that signer as unknown in `v1_signers_match_block_signers`. `certificates` lists every certificate the + block carries (SHA-256 and subject) and `signer_infos` gives each + `SignerInfo`'s certificate and digest/signature algorithm OIDs. + `certificate` is `null` when the block has more than one `SignerInfo` (with + nothing verified there is no single signer) or names a certificate it does + not carry. A block that cannot be read (malformed DER, an unsupported + compression method, Zip64 sizes, a local header that disagrees with the + central directory on the name, data-descriptor flag, CRC-32 or a size, as + apksig refuses it, or over a bound) keeps its names and carries + `decode_error`; it is never reported as an absent signer. apksig ignores + the ZIP encryption flag (bit 0), so a block that sets it is read as stored: + the PKCS#7 decoding decides (bytes that really are encrypted fail to + decode), and a limitation names the block. + apksig rejects the v1 signature of an archive whose central directory + holds any name more than once ("Duplicate entry"). REA checks every entry: + a limitation names the first few duplicates (JSON-quoted; a name over 256 + bytes is cut there and its stored length given) and counts the rest, and a + signer whose block, `.SF` or `META-INF/MANIFEST.MF` name is duplicated + (one signer per block name) carries `decode_error` + `duplicate entry: `, so a duplicated manifest marks every signer. + A duplicate outside those names leaves the signers as declared. + Names are compared as apksig compares them, as Java strings decoded from + UTF-8: malformed bytes become U+FFFD, so `dup\xff` and `dup\xfe` are one + name (apksigner: `Duplicate entry: dup�`). REA reproduces Java's + replacement rather than WHATWG `TextDecoder`'s; the two differ for encoded + surrogates (`ED A0..BF`), which Java replaces as one unit. This was checked + against OpenJDK 25's `new String(bytes, UTF_8)` on about 300,000 malformed + and well-formed sequences, including a leading byte-order mark, which Java + keeps; other Java runtimes are not checked. The same + decoding names v1 entries and compares a local header's name with the + central directory's; as in apksig, a local name longer in bytes than the + directory's is refused before decoding. apksig takes the record count from the classic + end-of-central-directory record's 16-bit field and ignores a Zip64 count, + so it reads only the first 65,535 records of a larger directory: the + limitation then says so, counts as duplicates for apksig only names whose + second copy lies within those records, and names separately those + duplicated only past them. REA still reads v1 signing entries, and flags + duplicated signing names, from every record, so past record 65,535 it + reports signers apksig would not see. + Bytes after the block's `ContentInfo` are ignored, as apksig's BER parser + ignores them, and a limitation names the block and their count. +- **Per signer:** the certificate's **SHA-256 fingerprint over the raw embedded + DER** (the same value `apksigner --print-certs` prints), plus subject, issuer, + serial, ISO 8601 validity and public-key algorithm when the X.509 parser can + read them (each is `null` otherwise); raw signature algorithm IDs, declared + digest hex and digest algorithms, signature lengths and public-key length; and for + v3/v3.1 the signer's declared min/max SDK. +- **Key rotation (v3/v3.1):** the declared proof-of-rotation lineage, oldest + first. Each node carries its certificate and the capability flags **retained + by that node's own certificate** after rotation (installed data, shared UID, + permission, rollback, auth). A lineage attribute that cannot be decoded is + reported with `decode_error`, never silently emptied. +- **Source stamp:** whether a source-stamp block is present and the stamp + certificate it declares (the V2 stamp block is preferred over V1, as apksig + does). `certificate` is `null` when no stamp is present, or when the block + could not be decoded and `decode_error` says why. Stamp signatures are not + verified. +- **Declared digest consistency:** for SHA-256/SHA-512 digests, REA recomputes + the v2/v3 content digest and reports `matches_apk` per digest and + `posture.self_consistent`. This only shows that the **declared** digest + equals the digest of the current bytes. It is **not a signature + verification**: anyone who changes the bytes can recompute the digest and + write it into the signed data, and REA still reports `true` while + `apksigner verify` rejects the APK. A changed byte with an unchanged + declared digest reads `false`. +- **Posture** (derived from the facts above): + - `v1_only`: v1 is present and no v2/v3/v3.1 block is. The v1 + `X-Android-APK-Signed` marker is not read, so this cannot distinguish an + APK signed only with v1 from one whose v2/v3 block was removed. + - `schemes_agree_on_signer` (observed): every present block scheme presents + the same _set_ of signer certificates. An extra signer in one scheme is + disagreement; a signer without a certificate makes it `null` (unknown). + Build-tools 33+ rotate through v3.1 by default, so a rotated app usually + reads `false` here and `true` below. + - `signer_change_declared_by_rotation` (declared): the signers differ, but + each appears in a lineage the APK declares. Read the two together: a + rotated APK shows `false` / `true`; signers that differ with no declared + lineage containing them show `false` / `false`. It is `null` when a + signer's identity is unknown, or when the signers differ and a declared + lineage could not be decoded. + - `v1_signers_match_block_signers` (observed): the v1 signer certificates + equal what the lowest block scheme that has signers (v2, else v3, else + v3.1) expects v1 to carry. That is its signer set, except that a v3/v3.1 + signer declaring a rotation lineage expects the lineage's first + (original) certificate, as apksig requires. `false` is notable: the JAR + signature names a different key than the block signature. A key rotation + keeps v1 (and v2) on the original key, so it reads `true` while + `schemes_agree_on_signer` reads `false`, with or without v2. `null` when + v1 or a block scheme is absent, any signer's identity is unknown (a + `decode_error`, a `null` certificate or a `subjectKeyIdentifier` + `SignerInfo`), or the lineage's original certificate cannot be + identified; a limitation then says why. + - `strongest_scheme`, `rotation_present`, `source_stamp_present`, + `self_consistent`. + +## What it does not claim + +This is **declared identity and digest consistency, not trust**. No signature +is cryptographically verified (including the v1 PKCS#7 signature and the +`.SF`/`MANIFEST.MF` digests, so a v1 signer is reported even where `keytool` +or `apksigner` reject the archive), certificate chains and revocation are not +evaluated, and nothing here says an app is authentic. `self_consistent: true` +means the declared digest equals the digest of the current bytes; it is not a +signature verification, and it does not prove who made the bytes. Rotation-lineage node signatures are not verified either, so a lineage is +what the APK _declares_: an attacker who re-signs an APK can declare any +lineage, which is why `signer_change_declared_by_rotation` is kept apart from +the observed agreement. SHA-256 identifies exact bytes, not authenticity. + +Not yet read: v4 `.apk.idsig` (a sibling file), verity (`0x0421`/`0x0423`/`0x0425`) digests (reported with +`matches_apk: null`), and the v1 `X-Android-APK-Signed` marker. + +## Bounds and errors + +The archive is read once, bounded at 512 MiB, without following symlinks and +with change detection. Every size and offset in the ZIP tail and signing block +is checked against its containing section before use, including the complete +central-directory extent before ZIP end records. A process-wide queue permits +one active whole-file inspection and eight pending scans across provider +instances; pending scans retain no APK bytes and can be cancelled with their +original reason. Further requests receive a capacity error. The active slot +is held through parsing and cleanup. Signers per scheme, v1 signers and +lineage nodes are capped at 32 each as resource admission limits, not format +maxima (apksig separately accepts at most 10 v1 signers). Each v1 block is inflated to at most 1 MiB, and its DER +is read within 32 levels of nesting and 65,536 elements, with at most 32 +certificates and 32 `SignerInfo`s; BER indefinite lengths are accepted. The report's size is +bounded from above by those counts before anything is materialised, and the exact +serialized size of the report together with its limitations (which repeat +archive names) is then checked against an 8 MiB output budget (the exact check +is authoritative); an over-budget report is refused, not truncated. + +The whole-file reader retains read chunks and a concatenated buffer; total +process memory exceeds the selected file's size. It also computes the complete +file SHA-256, so inspection is not header-only. Content digests are recomputed +at most once per SHA-256/SHA-512 algorithm during one inspection. Input, +inflation, decoded-record and serialized-output bounds keep work finite; +resource refusals are distinct from malformed format errors. + +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 +its bytes, or a malformed signing block or signer is an input error naming the +scheme and reason. Signer framing includes signature records, the public-key +field and matching signed/outer v3 SDK ranges. Suffix bytes beyond known +fields are counted as uninterpreted, as Android readers may ignore extensions; +their presence does not establish malformed signing. +When the central directory is not immediately followed by +the end-of-central-directory record (a declared-size mismatch or a Zip64 +record), the content digest is not recomputed and a limitation says so. A +rotation attribute that cannot be decoded still counts as `rotation_present` +(the attribute exists) and carries its `decode_error`. + +Verified by the `verify:apk-signature` real lane. It builds a real minimal APK +with `aapt2`, signs it with the SDK's `apksigner` (default v2+v3, v1-only with +RSA and EC keys, v1+v2+v3 on a legacy target, a rotated key lineage with and +without v1, rotation carried by v3.1, a source stamp, and a copy +with one changed byte), and cross-checks REA against +`apksigner verify --print-certs`, `apksigner lineage --print-certs` and +`keytool -printcert -jarfile` through the CLI and MCP. + +The real-toolchain CI lane uses pinned, checksum-verified Build Tools 36.1.0 +and platform 36 oracle files in its job workspace. It requires the existing +host JDK and runs `npm run verify:apk-signature -- --require-tools`; absent +oracles fail rather than silently establishing provider coverage. REA itself +requires none of these external tools. diff --git a/docs/architecture.mermaid b/docs/architecture.mermaid index abbef6f9e4..962bb0f1ba 100644 --- a/docs/architecture.mermaid +++ b/docs/architecture.mermaid @@ -29,6 +29,7 @@ graph TD PROVIDER_OWNERS["src/providers/
shared deferred client and inspection owners
exact cleanup retry and attributed failures"] PWNTOOLS["src/native/pwntools/ + src/native/pwndbg/
BYO offline ELF/core adapters
owned snapshots/Python and optional core-only GDB
unchanged upstream parsers, ABI structures and mapping API"] WASM["src/wasm/
caller-supplied WABT; immutable artifact inspection
owned process supervision and retryable workspace cleanup"] + APKSIG["src/apkSignature/
in-process APK Signing Block + v1 reader
bounded single read, no device or execution"] EVM["src/evm/
unchanged EVMole/WASM in bounded owned worker
explicit carrier parsing and inferred interface adapter"] HOPPER["src/hopper/
Hopper launch & Unix-socket protocol
BridgeLauncher, HopperClient, protocol"] GHIDRA["src/ghidra/
Ghidra discovery, profile & function analysis
snapshot, Unix/Windows transport, bounded lifecycle"] @@ -101,6 +102,8 @@ graph TD SERVER -->|narrow record ports| RECORDS RECORDS --> DOMAIN COMPOSITION --> WASM + COMPOSITION --> APKSIG + APKSIG --> DOMAIN WASM --> PROCESS WASM --> DOMAIN COMPOSITION --> PWNTOOLS diff --git a/docs/index.md b/docs/index.md index 90309f161d..5244acb858 100644 --- a/docs/index.md +++ b/docs/index.md @@ -47,19 +47,19 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md). ## Choose a guide -| Task | Guides | -| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) | -| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [ELF notes](elf-notes.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | -| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) | -| Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) | -| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) | -| Inspect assemblies, packages, or firmware | [Godot PCK](godot-pck.md), [.NET](managed-code-analysis.md), [Python packages](python-packaged-applications.md), [Android](android-analysis.md), [Apktool resources](apktool-resource-analysis.md), [ADB acquisition](adb-device-analysis.md), [Apple](apple-application-analysis.md), [Harmony](harmony-analysis.md), [firmware](firmware-analysis.md), [WASM](wasm-artifacts.md), [EVM](evm-bytecode.md) | -| Observe or compare a browser scenario | [CDP observation](browser-observation.md), [scenarios](browser-scenario-contract.md), [runtime evidence](web-runtime.md), [historical captures](web-network-captures.md) | -| Serve recorded behavior as an agent environment | [Agent environments](agent-environments.md) | -| Trace captured website source | [Source locations](web-source-location.md), [module traces](website-module-trace.md), [script export](website-script-export.md) | -| Capture and compare a command | [Process capture](process-capture.md) | -| Use CLI/MCP and retain evidence | [CLI](cli.md), [MCP contracts](mcp-contracts.md), [agent prompts](mcp-prompts.md), [obligation ledgers](reconstruction-obligation-ledgers.md) | +| Task | Guides | +| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) | +| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [ELF notes](elf-notes.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | +| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) | +| Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) | +| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) | +| Inspect assemblies, packages, or firmware | [Godot PCK](godot-pck.md), [.NET](managed-code-analysis.md), [Python packages](python-packaged-applications.md), [Android](android-analysis.md), [APK signatures](apk-signature.md), [Apktool resources](apktool-resource-analysis.md), [ADB acquisition](adb-device-analysis.md), [Apple](apple-application-analysis.md), [Harmony](harmony-analysis.md), [firmware](firmware-analysis.md), [WASM](wasm-artifacts.md), [EVM](evm-bytecode.md) | +| Observe or compare a browser scenario | [CDP observation](browser-observation.md), [scenarios](browser-scenario-contract.md), [runtime evidence](web-runtime.md), [historical captures](web-network-captures.md) | +| Serve recorded behavior as an agent environment | [Agent environments](agent-environments.md) | +| Trace captured website source | [Source locations](web-source-location.md), [module traces](website-module-trace.md), [script export](website-script-export.md) | +| Capture and compare a command | [Process capture](process-capture.md) | +| Use CLI/MCP and retain evidence | [CLI](cli.md), [MCP contracts](mcp-contracts.md), [agent prompts](mcp-prompts.md), [obligation ledgers](reconstruction-obligation-ledgers.md) | For development, use [testing](testing.md), [client verification](client-verification.md), [tool design](tool-design.md), and [releasing](releasing.md). diff --git a/docs/specs/apk-signature/spec.md b/docs/specs/apk-signature/spec.md new file mode 100644 index 0000000000..facf5f1126 --- /dev/null +++ b/docs/specs/apk-signature/spec.md @@ -0,0 +1,113 @@ +# Inspect APK signature schemes (v1–v3.1) + +## Outcome and scope + +Answer, statically, **who signed a selected local APK, under which schemes, and +whether the declared content digests match the current bytes**. This closes a gap REA's own +Android inventory documents: "ZIP inventory cannot see the APK Signing Block, so +an empty signing array does not mean the APK is unsigned" +(`src/domain/android/androidApplication.ts`). + +Static format inspection within existing offline-artifact authority: no Android +code executes, no device or `adb`, no acquisition, no mutation. + +## Inspection contract + +- v1 (JAR) signing detected from the central directory as apksig selects it: + `META-INF/` entries at any depth ending in `.SF` or `.RSA`/`.DSA`/`.EC` + (case-sensitive), present only when a block has its matching `.SF`. +- Each paired v1 block's PKCS#7 `SignedData` read with bounded DER (BER + indefinite lengths accepted): every carried certificate, and per + `SignerInfo` the certificate it names by `issuerAndSerialNumber` or + `subjectKeyIdentifier` plus its digest/signature algorithm OIDs (apksig + resolves only `issuerAndSerialNumber`, so a `subjectKeyIdentifier` signer is + reported as declared, with a limitation, and is unknown to the posture's v1 + comparison). The signer + `certificate` goes through the same X.509 owner as v2/v3; it is `null` when + there is not exactly one `SignerInfo` or it names no carried certificate. A + block that cannot be read (including a local header that disagrees with + the central directory, as apksig checks) carries + `decode_error`, never an absent signer; so does a signer whose block, + `.SF` or `META-INF/MANIFEST.MF` name is duplicated. Duplicate names are + found among all central-directory entries, as apksig rejects the v1 + signature for any of them ("Duplicate entry"), and a limitation names the + first few (JSON-quoted, cut at 256 bytes with the stored length) and counts + the rest. Names are compared as apksig's Java UTF-8 decoding makes them, so + malformed bytes that decode to the same string are duplicates; where Zip64 + declares more records than the classic end-of-central-directory count that + apksig reads, duplicates only past those records are named separately. As + apksig does, the ZIP encryption flag is + ignored: the block is read and its decoding decides, with a limitation. +- The APK Signing Block located from the ZIP end-of-central-directory (Zip64 + followed) and framed into id-value pairs; v2, v3 and v3.1 signers decoded: + digests by signature-algorithm id, X.509 certificate DER, v3 min/max SDK, + additional attributes. +- Certificate identity as SHA-256 over the raw embedded DER (as apksig + hashes it); subject, issuer, serial, ISO 8601 validity and key algorithm via + the platform X.509 parser, each `null` when unreadable rather than guessed. +- The v3/v3.1 proof-of-rotation lineage (version 1) decoded into nodes: + certificate and the capability flags retained by that node's certificate. + Node signatures are not verified: the lineage is what the APK declares. A + lineage that cannot be decoded is reported with `decode_error`. +- Source-stamp presence and the declared stamp certificate (V2 block + preferred), or a `decode_error` when it cannot be read. +- Declared digest consistency: the v2/v3 content digest is recomputed for + SHA-256/SHA-512 and compared with each declared digest (`matches_apk`), + rolled up into `posture.self_consistent`. This is not a signature + verification: an attacker who changes the bytes can recompute and replace + the declared digest. Verity digests are not recomputed (`null`). +- A derived posture that keeps observation and declaration apart: + `schemes_agree_on_signer` compares each block scheme's signer certificate + _set_; `signer_change_declared_by_rotation` says whether every differing + signer appears in a declared lineage (`null` when identity or a needed + lineage is unknown); `v1_signers_match_block_signers` compares the v1 + signer set with what the lowest block scheme that has signers (v2, else + v3, else v3.1) expects: its signers, or the first (original) certificate + of a v3/v3.1 signer's declared lineage, as apksig requires; `null` when + either side's identity is unknown, with a limitation when the lineage + origin is the unknown side. Also `v1_only` (which cannot distinguish a v1-only APK + from one whose v2/v3 block was removed, since `X-Android-APK-Signed` is not + read), strongest scheme, rotation and source-stamp presence. +- Unknown is never reported as absent: a central directory that cannot be + walked, or whose record count does not consume its declared extent, is an + input error (v1 presence would otherwise be guessed); a signer without a + certificate makes agreement `null`; scheme errors name the scheme and reason. + +## Authority + +Declared identity and digest consistency, never trust: no cryptographic signature +verification, no certificate-chain or revocation evaluation, no authenticity +claim. A full verifier would be a separate scope decision. + +## Resource admission + +One bounded read of the archive (512 MiB cap, no-follow, change-detected). +Every ZIP-tail and signing-block size/offset is checked against the buffer. +Signers per scheme, v1 signers and lineage nodes are capped at 32 +(format-justified: one node per rotation, one v3 signer per SDK range; apksig +accepts at most 10 v1 signers); pairs and per-signer elements at 4096. Each v1 +block inflates to at most 1 MiB (bounded by the inflater, not by the declared +size), and its DER is read within 32 nesting levels and 65,536 elements, with +at most 32 certificates and 32 `SignerInfo`s. The result size is projected from decoded counts before +materialisation (an upper bound), and the exact serialized size of the result and its limitations is checked against +the 8 MiB output budget (authoritative); over-budget is a capacity refusal, not +truncation. The content digest is +computed at most once per algorithm in 1 MiB chunks. Peak memory is about +2.5 times the file size (the shared stable read keeps its chunks and then +concatenates them; 1.25 GiB measured for a 500 MiB APK, about 1.3 GB at the +cap) plus bounded decoding overhead; the digest passes run synchronously (a streamed positioned-read design is +a follow-up). + +## Verification + +`npm run verify:apk-signature` (Android SDK `apksigner` + `aapt2`, JDK +`keytool`): builds a real minimal APK, signs default v2+v3, v1-only (RSA and +EC), v1+v2+v3 on a legacy target, a rotated lineage with and without v1, rotation carried by v3.1, a source stamp and a copy with one changed byte, and cross-checks REA against `apksigner verify`, +`apksigner lineage` and `keytool -printcert -jarfile` through CLI and MCP in Evidence parity, with the advertised +schema validated. SDK-free unit and adapter tests cover framing (including Zip64 +and damaged archives), signer and lineage decoding with caps, the digest +algorithm (including a pooled-Buffer regression), set-based posture, the output +budget, and the provider's error surfaces. Independent review before handoff. + +Format reference: APK Signature Scheme v2/v3 — +https://source.android.com/docs/security/features/apksigning diff --git a/docs/verification/apk-signature-2026-10-22.json b/docs/verification/apk-signature-2026-10-22.json new file mode 100644 index 0000000000..bbe64bca26 --- /dev/null +++ b/docs/verification/apk-signature-2026-10-22.json @@ -0,0 +1,1644 @@ +{ + "issue": 4537, + "pull_request": 4158, + "recorded_at_utc": "2026-10-22T10:21:30.933320+00:00", + "runtime": { + "commit": "f85a887a05fa62d0675139746dbce6eba7548aee", + "tree": "ff1f42613847e198988bf84dfb2d50d578084bdb", + "original_pr_head": "76445a83c8e6542131f7b38290ec749b3a199ba4", + "main": "a4a548718d696d7329ffc7b9caa64ee72a215461", + "integration_commit": "b6e0147b44787a6af9f32d8a24d1ac27f42d03d2" + }, + "source_qualification": { + "executed_head": "b6e0147b44787a6af9f32d8a24d1ac27f42d03d2", + "qualified_index_tree": "ff1f42613847e198988bf84dfb2d50d578084bdb", + "runtime_commit": "f85a887a05fa62d0675139746dbce6eba7548aee", + "runtime_tree": "ff1f42613847e198988bf84dfb2d50d578084bdb", + "commit_tree_matches": true, + "method": "201 source checks and uncached fast gate after the real SDK suffix regression and current main integration. Hooks left the qualified tree identical." + }, + "baseline": { + "runtime": "e1f192e8696eb445f11fd0442046931f52457f2f", + "origin_pr": "76445a83c8e6542131f7b38290ec749b3a199ba4", + "real_toolchain": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "verify:apk-signature", + "--", + "--require-tools" + ], + "exit_code": 0, + "wall_s": 79.41713352499937, + "cpu_s": 104.67699400000001, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1576824832, + "sample_ms": 20, + "guard_stop": null + }, + "schema_qualification": "Fresh SDK-advertised schemas and all public/oracle/resource workflows qualify the current runtime after integration and suffix handling." + }, + "counts": { + "source": 201, + "compiled_public": 408, + "schema": 15, + "boundary": 393, + "source_suites": 12, + "compiled_suites": 14 + }, + "checks": [ + { + "name": "apk-extension-source", + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:local", + "--", + "src/apkSignature/ApkSignatureProvider.test.ts", + "src/apkSignature/apkV1Signers.test.ts", + "src/application/apkSignature/ApkSignatureService.test.ts", + "src/domain/apkSignature/apkContentDigest.test.ts", + "src/domain/apkSignature/apkSignatureReport.test.ts", + "src/domain/apkSignature/apkSignatureSchemes.test.ts", + "src/domain/apkSignature/apkSigningBlock.test.ts", + "src/domain/apkSignature/apkV1Signature.test.ts", + "src/domain/apkSignature/apkV1SignatureBlock.test.ts", + "src/domain/apkSignature/zipEntryName.test.ts", + "src/domain/analysisView/analysisView.test.ts", + "src/application/analysisView/AnalysisViewService.test.ts" + ], + "exit_code": 0, + "wall_s": 9.3979273649893, + "cpu_s": 13.944163, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1118638080, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-extension-fast", + "command": ["taskset", "-c", "0,1", "npm", "run", "check:fast"], + "exit_code": 0, + "wall_s": 12.29217481998785, + "cpu_s": 20.611625, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1592041472, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-current-public-schemas", + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:focused", + "--", + "tests/boundary/mcp/toolSchemaValidity.test.ts" + ], + "exit_code": 0, + "wall_s": 46.34033803200873, + "cpu_s": 52.04988, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1937432576, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-current-public-boundaries", + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:focused", + "--", + "tests/acceptance/apkSignatureV1Cli.test.ts", + "tests/acceptance/apkSignatureViewsCliMcp.test.ts", + "tests/boundary/filesystem/apkSignatureViewIdentity.test.ts", + "tests/process-global/apkSignatureLifecycle.test.ts", + "tests/boundary/providers/artifactReadCleanupRetry.test.ts", + "tests/process-global/stableArtifactConsumers.test.ts", + "tests/process-global/artifactReadFailurePrecedence.test.ts", + "tests/boundary/filesystem/stableArtifact.test.ts", + "tests/boundary/mcp/advertisedPatternsVMode.test.ts", + "tests/conformance/contracts/mcpInputSchemaProfile.test.ts", + "tests/boundary/mcp/absoluteLocalPathSchema.test.ts", + "tests/boundary/filesystem/ciChangeScope.test.ts", + "tests/boundary/filesystem/ciRequiredLanes.test.ts" + ], + "exit_code": 0, + "wall_s": 79.56373987600091, + "cpu_s": 145.49878999999999, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 3186917376, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-current-real-toolchain", + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "verify:apk-signature", + "--", + "--require-tools" + ], + "exit_code": 0, + "wall_s": 57.384587647989974, + "cpu_s": 76.09016, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1189261312, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-current-task-full", + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/bench-apk.mjs", + "complete", + "task-current-full" + ], + "exit_code": 0, + "wall_s": 3.6120893549959874, + "cpu_s": 4.339207, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 698650624, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-current-task-selected", + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/bench-apk.mjs", + "selected", + "task-current-selected" + ], + "exit_code": 0, + "wall_s": 3.5080085529916687, + "cpu_s": 4.384262, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 700706816, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-current-resources", + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/probe-apk-resources.mjs" + ], + "exit_code": 0, + "wall_s": 2.0474989989888854, + "cpu_s": 2.392515, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 449503232, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-current-ci-static", + "command": ["taskset", "-c", "0,1", "npm", "run", "check:ci:fast"], + "exit_code": 0, + "wall_s": 10.59372954099672, + "cpu_s": 16.226353, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1556914176, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-docs-check", + "command": ["taskset", "-c", "0,1", "npm", "run", "docs:check"], + "exit_code": 0, + "wall_s": 13.23774031701032, + "cpu_s": 17.251783, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 808235008, + "sample_ms": 20, + "guard_stop": null + } + ], + "public_input": { + "original_url": "https://f-droid.org/F-Droid.apk", + "final_url": "https://f-droid.org/F-Droid.apk", + "path": "/home/yann/yann/rea-4537-lab/F-Droid.apk", + "bytes": 12569951, + "sha256": "19d51fad482239f6df6a99120e34513667e9a86d136dafdc4a211a68c3327b52", + "headers": { + "Content-Length": "12569951", + "Last-Modified": "Mon, 12 Oct 2026 13:59:40 GMT", + "ETag": "\"bfcd5f-65da51bf0f488\"" + }, + "execution": "downloaded as static data; never executed" + }, + "public_oracle": { + "v3_signers": [ + "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab" + ], + "method": "apksigner verify --verbose --print-certs reports v3 successfully verified; fingerprints refer to that effective signer set, not platform-wide signing compatibility" + }, + "generated_large_input": { + "path": "/home/yann/yann/rea-4537-lab/large-signed.apk", + "bytes": 180350384, + "sha256": "cf3a3936f90bb79dabe2598dc4290f8c47d10680993c054fe8cac9d391fadaaf", + "method": "Official real F-Droid input with a160MiB stored opaque asset, re-signed by apksigner with a generated fixture key. This generated stress artifact is distinct from the unmodified public APK. No Android code executes.", + "original_source_receipt": "fdroid-input-receipt.json", + "apksigner_verify_exit_code": 0 + }, + "oracle_toolchain": { + "sdk": "/home/yann/.cache/rea-4537-android-sdk", + "packages": [ + { + "path": "build-tools;36.1.0", + "size": 64195204, + "checksum": "936a0d6bd5ae3e2118a7567dddbc95ff67ed46e9", + "checksum_type": null, + "url": "https://dl.google.com/android/repository/build-tools_r36.1_linux.zip", + "sha1_verified": true, + "sha256": "a7b5889e4a79fcf3b0976bef40d401f4240fb1eed891d9d91169da1111e11d78", + "download_bytes": 64195204, + "destination": "/home/yann/.cache/rea-4537-android-sdk/build-tools/36.1.0", + "extracted": [ + "NOTICE.txt", + "aapt2", + "apksigner", + "lib/apksigner.jar", + "lib64/libLLVM_android.so", + "lib64/libbcc.so", + "lib64/libbcinfo.so", + "lib64/libc++.so", + "lib64/libc++.so.1", + "lib64/libclang_android.so", + "source.properties" + ] + }, + { + "path": "platforms;android-36", + "size": 65878410, + "checksum": "2c1a80dd4d9f7d0e6dd336ec603d9b5c55a6f576", + "checksum_type": null, + "url": "https://dl.google.com/android/repository/platform-36_r02.zip", + "sha1_verified": true, + "sha256": "37607369a28c5b640b3a7998868d45898ebcb777565a0e85f9acf36f29631d2e", + "download_bytes": 65878410, + "destination": "/home/yann/.cache/rea-4537-android-sdk/platforms/android-36", + "extracted": ["android.jar", "source.properties"] + } + ], + "java": "existing host keytool/JDK; no Java installation" + }, + "workflows": { + "original_complete": { + "mode": "before", + "label": "task-before", + "task": "Identify every declared v3 signer certificate fingerprint in the real F-Droid APK and export complete source Evidence", + "source": { + "original_url": "https://f-droid.org/F-Droid.apk", + "final_url": "https://f-droid.org/F-Droid.apk", + "path": "/home/yann/yann/rea-4537-lab/F-Droid.apk", + "bytes": 12569951, + "sha256": "19d51fad482239f6df6a99120e34513667e9a86d136dafdc4a211a68c3327b52", + "headers": { + "Content-Length": "12569951", + "Last-Modified": "Mon, 12 Oct 2026 13:59:40 GMT", + "ETag": "\"bfcd5f-65da51bf0f488\"" + }, + "execution": "downloaded as static data; never executed" + }, + "parent_evidence_id": "ev_bf39bbdae838b1af082a6e8dc4be0b39f29159037d4f445d90b5a919728fe34f", + "answer": [ + "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab" + ], + "used_input_declarations_plus_all_calls_estimated_tokens": 4156, + "whole_catalog_plus_all_calls_estimated_tokens": 1140214, + "elapsed_s": 2.949627783, + "token_accounting": "tokenx estimates; actual tools/list definitions and every argument/reply/export included; not actual model usage. Initialization and JSON-RPC envelopes unmeasured. Fresh process, OS cache warm. No repeated timing study.", + "profile": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/bench-apk.mjs", + "before", + "task-before" + ], + "exit_code": 0, + "wall_s": 3.2823771700059297, + "cpu_s": 4.0827800000000005, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 751788032, + "sample_ms": 20, + "guard_stop": null + } + }, + "current_complete": { + "mode": "complete", + "label": "task-current-full", + "task": "Identify every declared v3 signer certificate fingerprint in the real F-Droid APK and export complete source Evidence", + "source": { + "original_url": "https://f-droid.org/F-Droid.apk", + "final_url": "https://f-droid.org/F-Droid.apk", + "path": "/home/yann/yann/rea-4537-lab/F-Droid.apk", + "bytes": 12569951, + "sha256": "19d51fad482239f6df6a99120e34513667e9a86d136dafdc4a211a68c3327b52", + "headers": { + "Content-Length": "12569951", + "Last-Modified": "Mon, 12 Oct 2026 13:59:40 GMT", + "ETag": "\"bfcd5f-65da51bf0f488\"" + }, + "execution": "downloaded as static data; never executed" + }, + "parent_evidence_id": "ev_1a0c7752a0cec24756d2e82a7bb253b6708b52f408f0e6dcd819c5b871ffb49f", + "answer": [ + "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab" + ], + "used_input_declarations_plus_all_calls_estimated_tokens": 5246, + "whole_catalog_plus_all_calls_estimated_tokens": 1157394, + "elapsed_s": 3.275274836, + "token_accounting": "tokenx estimates; actual tools/list definitions and every argument/reply/export included; not actual model usage. Initialization and JSON-RPC envelopes unmeasured. Fresh process, OS cache warm. No repeated timing study.", + "profile": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/bench-apk.mjs", + "complete", + "task-current-full" + ], + "exit_code": 0, + "wall_s": 3.6120893549959874, + "cpu_s": 4.339207, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 698650624, + "sample_ms": 20, + "guard_stop": null + } + }, + "current_selected": { + "mode": "selected", + "label": "task-current-selected", + "task": "Identify every declared v3 signer certificate fingerprint in the real F-Droid APK and export complete source Evidence", + "source": { + "original_url": "https://f-droid.org/F-Droid.apk", + "final_url": "https://f-droid.org/F-Droid.apk", + "path": "/home/yann/yann/rea-4537-lab/F-Droid.apk", + "bytes": 12569951, + "sha256": "19d51fad482239f6df6a99120e34513667e9a86d136dafdc4a211a68c3327b52", + "headers": { + "Content-Length": "12569951", + "Last-Modified": "Mon, 12 Oct 2026 13:59:40 GMT", + "ETag": "\"bfcd5f-65da51bf0f488\"" + }, + "execution": "downloaded as static data; never executed" + }, + "parent_evidence_id": "ev_1a0c7752a0cec24756d2e82a7bb253b6708b52f408f0e6dcd819c5b871ffb49f", + "answer": [ + "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab" + ], + "used_input_declarations_plus_all_calls_estimated_tokens": 4230, + "whole_catalog_plus_all_calls_estimated_tokens": 1156378, + "elapsed_s": 3.203434176, + "token_accounting": "tokenx estimates; actual tools/list definitions and every argument/reply/export included; not actual model usage. Initialization and JSON-RPC envelopes unmeasured. Fresh process, OS cache warm. No repeated timing study.", + "profile": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/bench-apk.mjs", + "selected", + "task-current-selected" + ], + "exit_code": 0, + "wall_s": 3.5080085529916687, + "cpu_s": 4.384262, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 700706816, + "sample_ms": 20, + "guard_stop": null + } + } + }, + "resources": { + "observations": [ + { + "source": "fdroid-input-receipt.json", + "bytes": 12569951, + "sha256": "19d51fad482239f6df6a99120e34513667e9a86d136dafdc4a211a68c3327b52", + "run": "first-provider-inspection", + "elapsed_ms": 98.72469700000002, + "cpu_ms": 123.829, + "signers": 1, + "declared_content_digests_match": true + }, + { + "source": "fdroid-input-receipt.json", + "bytes": 12569951, + "sha256": "19d51fad482239f6df6a99120e34513667e9a86d136dafdc4a211a68c3327b52", + "run": "repeated-provider-inspection-1", + "elapsed_ms": 70.34426400000001, + "cpu_ms": 87.361, + "signers": 1, + "declared_content_digests_match": true + }, + { + "source": "fdroid-input-receipt.json", + "bytes": 12569951, + "sha256": "19d51fad482239f6df6a99120e34513667e9a86d136dafdc4a211a68c3327b52", + "run": "repeated-provider-inspection-2", + "elapsed_ms": 50.83975299999997, + "cpu_ms": 55.557, + "signers": 1, + "declared_content_digests_match": true + }, + { + "source": "large-input-receipt.json", + "bytes": 180350384, + "sha256": "cf3a3936f90bb79dabe2598dc4290f8c47d10680993c054fe8cac9d391fadaaf", + "run": "first-provider-inspection", + "elapsed_ms": 592.576424, + "cpu_ms": 674.015, + "signers": 1, + "declared_content_digests_match": true + }, + { + "source": "large-input-receipt.json", + "bytes": 180350384, + "sha256": "cf3a3936f90bb79dabe2598dc4290f8c47d10680993c054fe8cac9d391fadaaf", + "run": "repeated-provider-inspection-1", + "elapsed_ms": 535.900784, + "cpu_ms": 645.28, + "signers": 1, + "declared_content_digests_match": true + }, + { + "source": "large-input-receipt.json", + "bytes": 180350384, + "sha256": "cf3a3936f90bb79dabe2598dc4290f8c47d10680993c054fe8cac9d391fadaaf", + "run": "repeated-provider-inspection-2", + "elapsed_ms": 480.12222499999984, + "cpu_ms": 553.147, + "signers": 1, + "declared_content_digests_match": true + }, + { + "case": "512MiB+1 sparse input", + "result": "capacity refusal before whole-file allocation" + } + ], + "conditions": "First versus repeated provider calls in one fresh process; OS caches warm, not disk-cold measurements. Artifact identity and digest matches checked each call. Whole-task external CPU/RSS profiling recorded separately. Generated180MB input has a fixture signing key and is distinct from the unchanged public F-Droid APK." + }, + "material_failed_validation": [ + { + "stage": "Real SDK detected our overly strict signed-data validation", + "profile": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "verify:apk-signature", + "--", + "--require-tools" + ], + "exit_code": 1, + "wall_s": 8.999729584000306, + "cpu_s": 10.715287, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 944664576, + "sample_ms": 20, + "guard_stop": null + }, + "reason": "The SDK producer appends an empty fourth signed-data field and readers ignore suffixes. Our exact-end assertion rejected valid signed APKs. The canonical decoder now retains uninterpreted byte counts at scheme, signer, signed-data and algorithm-record boundaries; outer required fields remain bounds-checked. Initial universal suffix-refusal claims are withdrawn." + }, + { + "stage": "Initial producer regression", + "profile": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:local", + "--", + "src/domain/apkSignature/apkSignatureSchemes.test.ts", + "src/apkSignature/ApkSignatureProvider.test.ts" + ], + "exit_code": 1, + "wall_s": 2.222008133001509, + "cpu_s": 3.0101139999999997, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 625840128, + "sample_ms": 20, + "guard_stop": null + }, + "failed": 4, + "passed_controls": 28, + "withdrawn_assertions": 1, + "reason": "Two incomplete framing cases and missing raw algorithm facts were genuine. The fourth initial assertion incorrectly rejected suffix bytes; real SDK/AOSP evidence superseded it." + }, + { + "stage": "First expanded public matrix", + "profile": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:focused", + "--", + "tests/acceptance/apkSignatureViewsCliMcp.test.ts", + "tests/acceptance/apkSignatureV1Cli.test.ts" + ], + "exit_code": 1, + "wall_s": 77.1766701380111, + "cpu_s": 117.809484, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 2625191936, + "sample_ms": 20, + "guard_stop": null + }, + "failed": 2, + "passed": 7, + "reason": "New test oracles omitted mandatory identity columns from a strict picked schema and mistook an identified subjectKeyIdentifier certificate for unknown identity. Corrected assertions; a genuinely undecodable v1 block now proves partial identity." + }, + { + "stage": "Frozen expanded public matrix", + "profile": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:focused", + "--", + "tests/acceptance/apkSignatureV1Cli.test.ts", + "tests/acceptance/apkSignatureViewsCliMcp.test.ts", + "tests/boundary/filesystem/apkSignatureViewIdentity.test.ts", + "tests/process-global/apkSignatureLifecycle.test.ts", + "tests/boundary/providers/artifactReadCleanupRetry.test.ts", + "tests/process-global/stableArtifactConsumers.test.ts", + "tests/process-global/artifactReadFailurePrecedence.test.ts", + "tests/boundary/filesystem/stableArtifact.test.ts", + "tests/boundary/mcp/advertisedPatternsVMode.test.ts", + "tests/conformance/contracts/mcpInputSchemaProfile.test.ts", + "tests/boundary/mcp/absoluteLocalPathSchema.test.ts" + ], + "exit_code": 1, + "wall_s": 146.19622675600112, + "cpu_s": 209.421463, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 2977558528, + "sample_ms": 20, + "guard_stop": null + }, + "failed": 2, + "passed": 77, + "reason": "Two existing CLI-heavy v1 tests exceeded their unchanged30s guard under the larger matrix. Known SDK tool definitions now avoid unrelated catalog compilation; the stateless malformed-input matrix shares a production stdio MCP connection while retaining representative CLI observations and all original assertions." + } + ], + "additional_test_preparation_failures": [ + "A source golden compared Buffer and Uint8Array object classes rather than equivalent byte values; compare bytes now.", + "The cache test initially borrowed a member before sealing the owner snapshot, then used an incorrect helper argument during correction. The final test seals canonical normalized facts and proves subject checks on cached reads.", + "A benchmark launcher first lacked its local dependency link; its failed setup receipt remains separate from the completed baseline task." + ], + "limits": [ + "Static declared signing metadata and content-digest equality do not establish authenticity or cryptographic signature/lineage verification. v1 entry coverage remains unknown.", + "Full source Evidence is retained/exported; it does not contain the complete APK payload. Source URLs, local paths and complete-file SHA-256 remain recorded.", + "Selection changes delivery, not whole-file analysis: file SHA-256 and supported declared content digests are still computed. Each algorithm is recomputed at most once per inspection.", + "SDK task token counts are tokenx estimates of actual used full input declarations plus all arguments/replies and Evidence export. Whole-catalog costs are separate; actual model tokens, initialization and JSON-RPC envelopes are unmeasured.", + "Fresh server/process task observations use warm OS caches; provider probes label first versus repeated in-process calls. No disk-cold, statistically stable latency or causal speedup claim.", + "CPU is payload-family wait-rusage delta and sampled RSS sums all descendant processes every20ms. Heavy commands are serial on two CPUs, with8GiB/420s family guards.", + "All executed real APK/toolchain qualification is Linux. Windows/macOS APK behavior and the newly added GitHub workflow are not established by local tests; current-head CI/review must be checked separately." + ], + "author_scope": "Builds on the existing contributor PR and commits. Follow-up account gensokyo-dev; no coauthor trailers added.", + "spec_sources": [ + "https://source.android.com/docs/security/features/apksigning/v2", + "https://source.android.com/docs/security/features/apksigning/v3", + "https://developer.android.com/tools/apksigner" + ], + "review_followup": { + "runtime": { + "commit": "35a9b13adaaccff109125664898d99886d76d959", + "tree": "db41f5fbee2d1dca69544c221324daa8238b8e8b", + "previous_runtime": "f85a887a05fa62d0675139746dbce6eba7548aee", + "base_head": "c1f6d5584366dde66d2bee8aa6cc27690e106a99", + "source_tests": 216, + "suites": 13, + "qualified_tree_matches": true + }, + "source_tests": 216, + "compiled_boundary_tests": 394, + "real_sdk_cases": 23, + "reproduction": { + "published_head": "c1f6d5584366dde66d2bee8aa6cc27690e106a99", + "failed": 13, + "controls_passed": 67, + "profile": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:local", + "--", + "src/domain/apkSignature/apkSignatureSchemes.test.ts", + "src/domain/apkSignature/apkV1SignatureBlock.test.ts", + "src/domain/apkSignature/apkSigningBlock.test.ts", + "src/domain/apkSignature/apkSignatureReport.test.ts", + "src/domain/apkSignature/apkV1Signature.test.ts", + "src/application/apkSignature/ApkSignatureService.test.ts" + ], + "exit_code": 1, + "wall_s": 4.358706866012653, + "cpu_s": 5.3162, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 699809792, + "sample_ms": 20, + "guard_stop": null + }, + "method": "Restored the published producer source for six owners temporarily with the final regression fixtures; finally restored each current source file. All thirteen failures are assertion mismatches, not fixture exceptions. The earlier preliminary probe included four invalid it.each fixture invocations; it is superseded by this corrected reproduction." + }, + "fixes": [ + "Canonical lineage parser checks both required algorithm fields and the signature length prefix; CMS SignerInfo checks the required signature OCTET STRING. Cryptographic verification remains unperformed.", + "Posture compares every present scheme, including empty signer sets; all-empty identity and v1 comparisons against empty schemes remain unknown. Corrected a test helper that had marked an absent v3 present in a v3.1 case.", + "ZIP tail owner bounds the full directory extent before classic or Zip64 end records; directory consumer rejects out-of-file extents instead of clamping.", + "One process-wide active APK scan and eight bounded waiters across provider instances, before filesystem reads; FIFO, queued cancellation reasons, double-release protection, failure capacity recovery and close/drain are covered.", + "Shared application service preserves the original pre-aborted signal reason without provider I/O." + ], + "checks": [ + { + "name": "apk-review-final-source", + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:local", + "--", + "src/apkSignature/ApkSignatureProvider.test.ts", + "src/apkSignature/apkV1Signers.test.ts", + "src/application/apkSignature/ApkSignatureService.test.ts", + "src/domain/apkSignature/apkContentDigest.test.ts", + "src/domain/apkSignature/apkSignatureReport.test.ts", + "src/domain/apkSignature/apkSignatureSchemes.test.ts", + "src/domain/apkSignature/apkSigningBlock.test.ts", + "src/domain/apkSignature/apkV1Signature.test.ts", + "src/domain/apkSignature/apkV1SignatureBlock.test.ts", + "src/domain/apkSignature/zipEntryName.test.ts", + "src/domain/analysisView/analysisView.test.ts", + "src/application/analysisView/AnalysisViewService.test.ts", + "src/apkSignature/ApkSignatureAdmission.test.ts" + ], + "exit_code": 0, + "wall_s": 9.289328743994702, + "cpu_s": 12.984254, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1201307648, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-review-fast-final", + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "check:fast", + "--", + "--force" + ], + "exit_code": 0, + "wall_s": 13.565608920005616, + "cpu_s": 21.578711000000002, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1569509376, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-review-real-toolchain", + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "verify:apk-signature", + "--", + "--require-tools" + ], + "exit_code": 0, + "wall_s": 59.42316622698854, + "cpu_s": 75.567539, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1264775168, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-review-public-boundaries", + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:focused", + "--", + "tests/acceptance/apkSignatureV1Cli.test.ts", + "tests/acceptance/apkSignatureViewsCliMcp.test.ts", + "tests/boundary/filesystem/apkSignatureViewIdentity.test.ts", + "tests/process-global/apkSignatureLifecycle.test.ts", + "tests/boundary/providers/artifactReadCleanupRetry.test.ts", + "tests/process-global/stableArtifactConsumers.test.ts", + "tests/process-global/artifactReadFailurePrecedence.test.ts", + "tests/boundary/filesystem/stableArtifact.test.ts", + "tests/boundary/mcp/advertisedPatternsVMode.test.ts", + "tests/conformance/contracts/mcpInputSchemaProfile.test.ts", + "tests/boundary/mcp/absoluteLocalPathSchema.test.ts", + "tests/boundary/filesystem/ciChangeScope.test.ts", + "tests/boundary/filesystem/ciRequiredLanes.test.ts" + ], + "exit_code": 0, + "wall_s": 85.34945754200453, + "cpu_s": 146.213631, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 3087822848, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-review-task-full", + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/bench-apk.mjs", + "complete", + "task-review-full" + ], + "exit_code": 0, + "wall_s": 3.564338846001192, + "cpu_s": 4.288907, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 754368512, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-review-task-selected", + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/bench-apk.mjs", + "selected", + "task-review-selected" + ], + "exit_code": 0, + "wall_s": 3.824455965994275, + "cpu_s": 4.271281, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 762630144, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-review-concurrent-after", + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/probe-apk-concurrent.mjs", + "concurrent-after-receipt" + ], + "exit_code": 0, + "wall_s": 2.256807152996771, + "cpu_s": 2.491306, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 448929792, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-review-ci-static", + "command": ["taskset", "-c", "0,1", "npm", "run", "check:ci:fast"], + "exit_code": 0, + "wall_s": 10.934351353003876, + "cpu_s": 16.392196000000002, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1554677760, + "sample_ms": 20, + "guard_stop": null + } + ], + "workflows": { + "complete": { + "mode": "complete", + "label": "task-review-full", + "task": "Identify every declared v3 signer certificate fingerprint in the real F-Droid APK and export complete source Evidence", + "source": { + "original_url": "https://f-droid.org/F-Droid.apk", + "final_url": "https://f-droid.org/F-Droid.apk", + "path": "/home/yann/yann/rea-4537-lab/F-Droid.apk", + "bytes": 12569951, + "sha256": "19d51fad482239f6df6a99120e34513667e9a86d136dafdc4a211a68c3327b52", + "headers": { + "Content-Length": "12569951", + "Last-Modified": "Mon, 12 Oct 2026 13:59:40 GMT", + "ETag": "\"bfcd5f-65da51bf0f488\"" + }, + "execution": "downloaded as static data; never executed" + }, + "parent_evidence_id": "ev_1a0c7752a0cec24756d2e82a7bb253b6708b52f408f0e6dcd819c5b871ffb49f", + "answer": [ + "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab" + ], + "used_input_declarations_plus_all_calls_estimated_tokens": 5243, + "whole_catalog_plus_all_calls_estimated_tokens": 1157391, + "elapsed_s": 3.245837959, + "token_accounting": "tokenx estimates; actual tools/list definitions and every argument/reply/export included; not actual model usage. Initialization and JSON-RPC envelopes unmeasured. Fresh process, OS cache warm. No repeated timing study.", + "profile": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/bench-apk.mjs", + "complete", + "task-review-full" + ], + "exit_code": 0, + "wall_s": 3.564338846001192, + "cpu_s": 4.288907, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 754368512, + "sample_ms": 20, + "guard_stop": null + } + }, + "selected": { + "mode": "selected", + "label": "task-review-selected", + "task": "Identify every declared v3 signer certificate fingerprint in the real F-Droid APK and export complete source Evidence", + "source": { + "original_url": "https://f-droid.org/F-Droid.apk", + "final_url": "https://f-droid.org/F-Droid.apk", + "path": "/home/yann/yann/rea-4537-lab/F-Droid.apk", + "bytes": 12569951, + "sha256": "19d51fad482239f6df6a99120e34513667e9a86d136dafdc4a211a68c3327b52", + "headers": { + "Content-Length": "12569951", + "Last-Modified": "Mon, 12 Oct 2026 13:59:40 GMT", + "ETag": "\"bfcd5f-65da51bf0f488\"" + }, + "execution": "downloaded as static data; never executed" + }, + "parent_evidence_id": "ev_1a0c7752a0cec24756d2e82a7bb253b6708b52f408f0e6dcd819c5b871ffb49f", + "answer": [ + "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab" + ], + "used_input_declarations_plus_all_calls_estimated_tokens": 4227, + "whole_catalog_plus_all_calls_estimated_tokens": 1156375, + "elapsed_s": 3.399602018, + "token_accounting": "tokenx estimates; actual tools/list definitions and every argument/reply/export included; not actual model usage. Initialization and JSON-RPC envelopes unmeasured. Fresh process, OS cache warm. No repeated timing study.", + "profile": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/bench-apk.mjs", + "selected", + "task-review-selected" + ], + "exit_code": 0, + "wall_s": 3.824455965994275, + "cpu_s": 4.271281, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 762630144, + "sample_ms": 20, + "guard_stop": null + } + } + }, + "concurrent_three_providers": { + "before_runtime": "f85a887a05fa62d0675139746dbce6eba7548aee", + "after_runtime": "35a9b13adaaccff109125664898d99886d76d959", + "before_profile": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/probe-apk-concurrent.mjs", + "concurrent-before-receipt" + ], + "exit_code": 0, + "wall_s": 2.354804062008043, + "cpu_s": 3.115582, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 981049344, + "sample_ms": 20, + "guard_stop": null + }, + "after_profile": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/probe-apk-concurrent.mjs", + "concurrent-after-receipt" + ], + "exit_code": 0, + "wall_s": 2.256807152996771, + "cpu_s": 2.491306, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 448929792, + "sample_ms": 20, + "guard_stop": null + }, + "before_observations": { + "observations": [ + { + "index": 0, + "complete_ms": 2094.634754, + "sha256": "cf3a3936f90bb79dabe2598dc4290f8c47d10680993c054fe8cac9d391fadaaf", + "bytes": 180350384, + "signers": 1, + "declared_digests_match": true + }, + { + "index": 1, + "complete_ms": 1726.915695, + "sha256": "cf3a3936f90bb79dabe2598dc4290f8c47d10680993c054fe8cac9d391fadaaf", + "bytes": 180350384, + "signers": 1, + "declared_digests_match": true + }, + { + "index": 2, + "complete_ms": 1356.759936, + "sha256": "cf3a3936f90bb79dabe2598dc4290f8c47d10680993c054fe8cac9d391fadaaf", + "bytes": 180350384, + "signers": 1, + "declared_digests_match": true + } + ], + "conditions": "Three simultaneous scans through three separate provider instances in one fresh process, same generated/re-signed180MB APK, warm OS cache. Exact identity, SDK fingerprints and content-digest equality checked for all results. No target execution." + }, + "after_observations": { + "observations": [ + { + "index": 0, + "complete_ms": 747.652685, + "sha256": "cf3a3936f90bb79dabe2598dc4290f8c47d10680993c054fe8cac9d391fadaaf", + "bytes": 180350384, + "signers": 1, + "declared_digests_match": true + }, + { + "index": 1, + "complete_ms": 1386.2275880000002, + "sha256": "cf3a3936f90bb79dabe2598dc4290f8c47d10680993c054fe8cac9d391fadaaf", + "bytes": 180350384, + "signers": 1, + "declared_digests_match": true + }, + { + "index": 2, + "complete_ms": 2019.695331, + "sha256": "cf3a3936f90bb79dabe2598dc4290f8c47d10680993c054fe8cac9d391fadaaf", + "bytes": 180350384, + "signers": 1, + "declared_digests_match": true + } + ], + "conditions": "Three simultaneous scans through three separate provider instances in one fresh process, same generated/re-signed180MB APK, warm OS cache. Exact identity, SDK fingerprints and content-digest equality checked for all results. No target execution." + }, + "limits": "Three simultaneous requests for the same generated/re-signed180MB APK in separate providers, fresh process and warm OS cache. Single observations, not a statistical or disk-cold result. Exact SHA, SDK fingerprints and declared digest equality asserted for every completed scan. CPU/RSS sampling uses the same20ms process-family guard." + }, + "failed_preparations": [ + "Directory arguments to test:local were refused; repeated using exact test paths.", + "Two new tests exceeded the describe callback line budget; moved distinct regressions to top level.", + "Promise.withResolvers was unsupported by the pinned TypeScript library; used a local deferred fixture." + ], + "limits": "The original record remains pinned to f85. This section supersedes its current source counts, runtime, whole-task profiles and concurrent memory claims. The earlier fifteen schema checks remain historical; named contracts and advertised schemas are unchanged in this follow-up. Linux only; no cryptographic authenticity, Android execution or disk-cold claim." + }, + "latest_main_integration": { + "runtime": { + "commit": "8f8541c39586d78911e4743d6ec07f75c449a0d5", + "tree": "50b93dccf15c40f9067030614d743d59e0aa334c", + "main": "2c3dd946d59c870388b03c3417eeb5218af96eeb", + "previous_runtime": "35a9b13adaaccff109125664898d99886d76d959", + "previous_published_head": "d600c2eaccb132b77946773762b0ddd2f7eb3b46", + "conflicts_resolved": [ + "docs/index.md", + "scripts/lib/test-projects.mjs", + "src/contracts/toolEffects.ts" + ], + "resolution": "Preserve both APK and upstream Python pyc links, source adapters and named evidence-effect entries. CLI/MCP/catalog composition merged additively; no APK analysis/selection/identity implementation changed." + }, + "reason": "The previous published head passed all technical CI and5/5 Greptile review. New main commit2c3dd94 added Python pyc and produced three additive registration/documentation conflicts before merge. Both capabilities are preserved, then the actual integrated tree is qualified again.", + "checks": [ + { + "name": "apk-main3-fast", + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "check:fast", + "--", + "--force" + ], + "exit_code": 0, + "wall_s": 11.869729917001678, + "cpu_s": 21.729281, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1614295040, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-main3-source", + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:local", + "--", + "src/apkSignature/ApkSignatureProvider.test.ts", + "src/apkSignature/apkV1Signers.test.ts", + "src/application/apkSignature/ApkSignatureService.test.ts", + "src/domain/apkSignature/apkContentDigest.test.ts", + "src/domain/apkSignature/apkSignatureReport.test.ts", + "src/domain/apkSignature/apkSignatureSchemes.test.ts", + "src/domain/apkSignature/apkSigningBlock.test.ts", + "src/domain/apkSignature/apkV1Signature.test.ts", + "src/domain/apkSignature/apkV1SignatureBlock.test.ts", + "src/domain/apkSignature/zipEntryName.test.ts", + "src/domain/analysisView/analysisView.test.ts", + "src/application/analysisView/AnalysisViewService.test.ts", + "src/apkSignature/ApkSignatureAdmission.test.ts", + "src/contracts/toolEffects.test.ts" + ], + "exit_code": 0, + "wall_s": 11.371356549003394, + "cpu_s": 18.763133, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1359224832, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-main3-real-toolchain", + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "verify:apk-signature", + "--", + "--require-tools" + ], + "exit_code": 0, + "wall_s": 53.99845462899248, + "cpu_s": 75.140853, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1215447040, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-main3-boundaries", + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:focused", + "--", + "tests/acceptance/apkSignatureV1Cli.test.ts", + "tests/acceptance/apkSignatureViewsCliMcp.test.ts", + "tests/boundary/filesystem/apkSignatureViewIdentity.test.ts", + "tests/process-global/apkSignatureLifecycle.test.ts", + "tests/boundary/providers/artifactReadCleanupRetry.test.ts", + "tests/process-global/stableArtifactConsumers.test.ts", + "tests/process-global/artifactReadFailurePrecedence.test.ts", + "tests/boundary/filesystem/stableArtifact.test.ts", + "tests/boundary/mcp/advertisedPatternsVMode.test.ts", + "tests/conformance/contracts/mcpInputSchemaProfile.test.ts", + "tests/boundary/mcp/absoluteLocalPathSchema.test.ts", + "tests/boundary/filesystem/ciChangeScope.test.ts", + "tests/boundary/filesystem/ciRequiredLanes.test.ts", + "tests/acceptance/tools/localToolContracts.test.ts", + "tests/boundary/mcp/toolSchemaValidity.test.ts" + ], + "exit_code": 0, + "wall_s": 169.26843295599974, + "cpu_s": 302.63194, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 3081945088, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-main3-task-full", + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/bench-apk.mjs", + "complete", + "task-main3-full" + ], + "exit_code": 0, + "wall_s": 3.6159018110047327, + "cpu_s": 4.389079, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 709554176, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-main3-task-selected", + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/bench-apk.mjs", + "selected", + "task-main3-selected" + ], + "exit_code": 0, + "wall_s": 3.798854296997888, + "cpu_s": 4.825477, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 717103104, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-main3-ci-static", + "command": ["taskset", "-c", "0,1", "npm", "run", "check:ci:fast"], + "exit_code": 0, + "wall_s": 10.638468119010213, + "cpu_s": 16.748921, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1563242496, + "sample_ms": 20, + "guard_stop": null + } + ], + "source_tests": 222, + "compiled_public_tests": 416, + "real_sdk_cases": 23, + "workflows": { + "complete": { + "mode": "complete", + "label": "task-main3-full", + "task": "Identify every declared v3 signer certificate fingerprint in the real F-Droid APK and export complete source Evidence", + "source": { + "original_url": "https://f-droid.org/F-Droid.apk", + "final_url": "https://f-droid.org/F-Droid.apk", + "path": "/home/yann/yann/rea-4537-lab/F-Droid.apk", + "bytes": 12569951, + "sha256": "19d51fad482239f6df6a99120e34513667e9a86d136dafdc4a211a68c3327b52", + "headers": { + "Content-Length": "12569951", + "Last-Modified": "Mon, 12 Oct 2026 13:59:40 GMT", + "ETag": "\"bfcd5f-65da51bf0f488\"" + }, + "execution": "downloaded as static data; never executed" + }, + "parent_evidence_id": "ev_1a0c7752a0cec24756d2e82a7bb253b6708b52f408f0e6dcd819c5b871ffb49f", + "answer": [ + "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab" + ], + "used_input_declarations_plus_all_calls_estimated_tokens": 5243, + "whole_catalog_plus_all_calls_estimated_tokens": 1162281, + "elapsed_s": 3.265332218, + "token_accounting": "tokenx estimates; actual tools/list definitions and every argument/reply/export included; not actual model usage. Initialization and JSON-RPC envelopes unmeasured. Fresh process, OS cache warm. No repeated timing study.", + "profile": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/bench-apk.mjs", + "complete", + "task-main3-full" + ], + "exit_code": 0, + "wall_s": 3.6159018110047327, + "cpu_s": 4.389079, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 709554176, + "sample_ms": 20, + "guard_stop": null + } + }, + "selected": { + "mode": "selected", + "label": "task-main3-selected", + "task": "Identify every declared v3 signer certificate fingerprint in the real F-Droid APK and export complete source Evidence", + "source": { + "original_url": "https://f-droid.org/F-Droid.apk", + "final_url": "https://f-droid.org/F-Droid.apk", + "path": "/home/yann/yann/rea-4537-lab/F-Droid.apk", + "bytes": 12569951, + "sha256": "19d51fad482239f6df6a99120e34513667e9a86d136dafdc4a211a68c3327b52", + "headers": { + "Content-Length": "12569951", + "Last-Modified": "Mon, 12 Oct 2026 13:59:40 GMT", + "ETag": "\"bfcd5f-65da51bf0f488\"" + }, + "execution": "downloaded as static data; never executed" + }, + "parent_evidence_id": "ev_1a0c7752a0cec24756d2e82a7bb253b6708b52f408f0e6dcd819c5b871ffb49f", + "answer": [ + "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab" + ], + "used_input_declarations_plus_all_calls_estimated_tokens": 4227, + "whole_catalog_plus_all_calls_estimated_tokens": 1161265, + "elapsed_s": 3.4879856169999996, + "token_accounting": "tokenx estimates; actual tools/list definitions and every argument/reply/export included; not actual model usage. Initialization and JSON-RPC envelopes unmeasured. Fresh process, OS cache warm. No repeated timing study.", + "profile": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/bench-apk.mjs", + "selected", + "task-main3-selected" + ], + "exit_code": 0, + "wall_s": 3.798854296997888, + "cpu_s": 4.825477, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 717103104, + "sample_ms": 20, + "guard_stop": null + } + } + }, + "unchanged_apk_owners": [ + "src/apkSignature", + "src/domain/apkSignature", + "src/application/apkSignature", + "src/domain/analysisView/apkSignatureView.ts", + "src/artifacts/readStableArtifact.ts" + ], + "concurrent_measurement_reuse": "The process-wide admission/provider/signing/view/readStableArtifact owners are byte-identical to35a9b13. The three-provider resource measurements remain explicitly pinned there, not relabeled as fresh measurements of this integration. MCP startup/catalog and whole-task costs were measured again below.", + "limits": "Same Linux/static-reader limitations as the earlier records. Fresh processes, warm OS caches, tokenx estimates, no statistical speed claim or cryptographic authenticity claim. Current-head CI and review follow publication." + }, + "latest_posture_fix": { + "runtime": { + "commit": "4fcbdf6fb78873b47f424c5e15ebda6af715befe", + "tree": "5cadce8a73107560d77045de481559b6eb6ef666", + "main": "2c3dd946d59c870388b03c3417eeb5218af96eeb", + "previous_runtime": "8f8541c39586d78911e4743d6ec07f75c449a0d5", + "previous_published_head": "481bcaaeeb96d30cd107e05d9a8d6afe8d0006fe", + "fixture_preparation_runtime": "cfcfdb35d4ffe58b815a5295d6bdf5cacd610b0e" + }, + "checks": [ + { + "name": "apk-posture-final-source", + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:local", + "--", + "src/apkSignature/ApkSignatureProvider.test.ts", + "src/apkSignature/apkV1Signers.test.ts", + "src/application/apkSignature/ApkSignatureService.test.ts", + "src/domain/apkSignature/apkContentDigest.test.ts", + "src/domain/apkSignature/apkSignatureReport.test.ts", + "src/domain/apkSignature/apkSignatureSchemes.test.ts", + "src/domain/apkSignature/apkSigningBlock.test.ts", + "src/domain/apkSignature/apkV1Signature.test.ts", + "src/domain/apkSignature/apkV1SignatureBlock.test.ts", + "src/domain/apkSignature/zipEntryName.test.ts", + "src/domain/analysisView/analysisView.test.ts", + "src/application/analysisView/AnalysisViewService.test.ts", + "src/apkSignature/ApkSignatureAdmission.test.ts", + "src/contracts/toolEffects.test.ts" + ], + "exit_code": 0, + "wall_s": 10.84718458999123, + "cpu_s": 17.49901, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1267478528, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-posture-public-fast-final", + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "check:fast", + "--", + "--force" + ], + "exit_code": 0, + "wall_s": 10.793883365011425, + "cpu_s": 19.700482, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1575550976, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-posture-real-toolchain", + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "verify:apk-signature", + "--", + "--require-tools" + ], + "exit_code": 0, + "wall_s": 39.48775443600607, + "cpu_s": 54.340119, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1238880256, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-posture-public", + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:focused", + "--", + "tests/acceptance/apkSignatureV1Cli.test.ts", + "tests/acceptance/apkSignatureViewsCliMcp.test.ts", + "tests/boundary/filesystem/apkSignatureViewIdentity.test.ts" + ], + "exit_code": 0, + "wall_s": 51.73035240599711, + "cpu_s": 92.88071599999999, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 3176570880, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-posture-task-full", + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/bench-apk.mjs", + "complete", + "task-posture-full" + ], + "exit_code": 0, + "wall_s": 3.8497771859983914, + "cpu_s": 4.732453, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 694124544, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-posture-task-selected", + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/bench-apk.mjs", + "selected", + "task-posture-selected" + ], + "exit_code": 0, + "wall_s": 3.5913410319917602, + "cpu_s": 4.666327, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 689983488, + "sample_ms": 20, + "guard_stop": null + }, + { + "name": "apk-posture-ci-static", + "command": ["taskset", "-c", "0,1", "npm", "run", "check:ci:fast"], + "exit_code": 0, + "wall_s": 11.573304717996507, + "cpu_s": 18.243097, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 1535651840, + "sample_ms": 20, + "guard_stop": null + } + ], + "source_tests": 225, + "public_cli_mcp_tests": 11, + "real_sdk_cases": 23, + "reproduction": { + "head": "481bcaaeeb96d30cd107e05d9a8d6afe8d0006fe", + "failed": 3, + "controls_passed": 14, + "profile": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:local", + "--", + "src/domain/apkSignature/apkSignatureReport.test.ts" + ], + "exit_code": 1, + "wall_s": 1.5553970670007402, + "cpu_s": 1.8651929999999997, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 415830016, + "sample_ms": 20, + "guard_stop": null + } + }, + "failed_fixture_preparation": { + "profile": { + "command": [ + "taskset", + "-c", + "0,1", + "npm", + "run", + "test:focused", + "--", + "tests/acceptance/apkSignatureV1Cli.test.ts", + "tests/acceptance/apkSignatureViewsCliMcp.test.ts", + "tests/boundary/filesystem/apkSignatureViewIdentity.test.ts" + ], + "exit_code": 1, + "wall_s": 50.94071782899846, + "cpu_s": 88.657178, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 3357618176, + "sample_ms": 20, + "guard_stop": null + }, + "reason": "The first public fixture encoded an empty certificate record, whose raw bytes have a known SHA-256, rather than omitting certificate records. The fixture now explicitly accepts null to encode an empty certificate sequence; no source-reader interpretation changed for this correction." + }, + "structural_fix": "Calculate signer-set differences once and share them between observed agreement and declared rotation attribution. A missing current signer identity remains null before lineage-origin substitution. No schema or raw-reader/admission change.", + "public_regression": "A correctly framed synthetic APK has a known CMS v1 signer, an unreadable v3 signing certificate and a readable lineage origin matching v1. CLI and actual SDK summary must retain unknown matching and partial identity; lineage facts remain queryable after file deletion.", + "workflows": { + "complete": { + "mode": "complete", + "label": "task-posture-full", + "task": "Identify every declared v3 signer certificate fingerprint in the real F-Droid APK and export complete source Evidence", + "source": { + "original_url": "https://f-droid.org/F-Droid.apk", + "final_url": "https://f-droid.org/F-Droid.apk", + "path": "/home/yann/yann/rea-4537-lab/F-Droid.apk", + "bytes": 12569951, + "sha256": "19d51fad482239f6df6a99120e34513667e9a86d136dafdc4a211a68c3327b52", + "headers": { + "Content-Length": "12569951", + "Last-Modified": "Mon, 12 Oct 2026 13:59:40 GMT", + "ETag": "\"bfcd5f-65da51bf0f488\"" + }, + "execution": "downloaded as static data; never executed" + }, + "parent_evidence_id": "ev_1a0c7752a0cec24756d2e82a7bb253b6708b52f408f0e6dcd819c5b871ffb49f", + "answer": [ + "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab" + ], + "used_input_declarations_plus_all_calls_estimated_tokens": 5246, + "whole_catalog_plus_all_calls_estimated_tokens": 1162284, + "elapsed_s": 3.5313849, + "token_accounting": "tokenx estimates; actual tools/list definitions and every argument/reply/export included; not actual model usage. Initialization and JSON-RPC envelopes unmeasured. Fresh process, OS cache warm. No repeated timing study.", + "profile": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/bench-apk.mjs", + "complete", + "task-posture-full" + ], + "exit_code": 0, + "wall_s": 3.8497771859983914, + "cpu_s": 4.732453, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 694124544, + "sample_ms": 20, + "guard_stop": null + } + }, + "selected": { + "mode": "selected", + "label": "task-posture-selected", + "task": "Identify every declared v3 signer certificate fingerprint in the real F-Droid APK and export complete source Evidence", + "source": { + "original_url": "https://f-droid.org/F-Droid.apk", + "final_url": "https://f-droid.org/F-Droid.apk", + "path": "/home/yann/yann/rea-4537-lab/F-Droid.apk", + "bytes": 12569951, + "sha256": "19d51fad482239f6df6a99120e34513667e9a86d136dafdc4a211a68c3327b52", + "headers": { + "Content-Length": "12569951", + "Last-Modified": "Mon, 12 Oct 2026 13:59:40 GMT", + "ETag": "\"bfcd5f-65da51bf0f488\"" + }, + "execution": "downloaded as static data; never executed" + }, + "parent_evidence_id": "ev_1a0c7752a0cec24756d2e82a7bb253b6708b52f408f0e6dcd819c5b871ffb49f", + "answer": [ + "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab" + ], + "used_input_declarations_plus_all_calls_estimated_tokens": 4230, + "whole_catalog_plus_all_calls_estimated_tokens": 1161268, + "elapsed_s": 3.28882079, + "token_accounting": "tokenx estimates; actual tools/list definitions and every argument/reply/export included; not actual model usage. Initialization and JSON-RPC envelopes unmeasured. Fresh process, OS cache warm. No repeated timing study.", + "profile": { + "command": [ + "taskset", + "-c", + "0,1", + "node", + "/home/yann/yann/rea-4537-lab/bench-apk.mjs", + "selected", + "task-posture-selected" + ], + "exit_code": 0, + "wall_s": 3.5913410319917602, + "cpu_s": 4.666327, + "cpu_accounting": "payload family wait rusage delta; inherited shell child usage excluded", + "peak_family_rss_bytes": 689983488, + "sample_ms": 20, + "guard_stop": null + } + } + }, + "scope": "The416-test integration/schema qualification stays pinned to8f8541c. Current focused public tests cover the changed semantic path; full current-head GitHub CI/review follows publication. Concurrent memory comparison remains a measurement at35a9b13; admission/stable-read code is unchanged, and current complete/selected tasks are freshly measured.", + "limits": "Same Linux, static-reader, unverified-authenticity and warm-cache/single-observation limits as earlier records." + } +} diff --git a/docs/verification/apk-signature-2026-10-22.md b/docs/verification/apk-signature-2026-10-22.md new file mode 100644 index 0000000000..6b56254916 --- /dev/null +++ b/docs/verification/apk-signature-2026-10-22.md @@ -0,0 +1,188 @@ +# APK signing verification + +## Current posture qualification + +Runtime `4fcbdf6fb78873b47f424c5e15ebda6af715befe`, tree `5cadce8a73107560d77045de481559b6eb6ef666`, +integrates main `2c3dd946d59c870388b03c3417eeb5218af96eeb`. The latest review exposed two additional +derived-fact errors: unchanged multi-signer sets could look rotated, and an +unknown current signer could be replaced with its readable lineage origin. +Three regressions failed against the previous published owner, with14 controls. +The owner now shares one signer-set comparison and preserves missing identity. + +- **225 source tests /14 suites**, **11 public CLI/MCP tests /3 suites**, + **23 real SDK cases**, uncached typecheck/lint and CI static passed. +- The new public regression proves the missing-identity result through real + CLI, SDK summary and retained lineage reads after source deletion. The + earlier416-test combined registration/schema matrix stays pinned to8f8541c; + contracts, raw framing and process-wide admission have not changed here. +- Current complete versus selected F-Droid task, same answer and complete + parent: **5,246 → 4,230 tokenx estimates**, + **3.850 → 3.591s** elapsed, + **4.732 → 4.666 CPU s**, + **694,124,544 → 689,983,488 bytes** peak family RSS. + +JSON `latest_posture_fix` records exact profiles and scope. The prior +three-provider comparison remains pinned to35a9b13. These are fresh-process, +warm-cache observations; actual model tokens, statistical speedups, +cryptographic authenticity and independent Windows/macOS behavior are unverified. +Current-head CI/review follows publication. + +## Integration qualification at8f8541c + +Runtime `8f8541c39586d78911e4743d6ec07f75c449a0d5`, tree `50b93dccf15c40f9067030614d743d59e0aa334c`, +integrates main `2c3dd946d59c870388b03c3417eeb5218af96eeb`. The previous published head passed all +technical CI and5/5 review before new upstream Python pyc registrations caused +three additive conflicts. Documentation links, test adapter coverage and named +tool effects now preserve both capabilities. The APK provider/domain/view and +stable-file reader are byte-identical to35a9b13. + +- **222 source tests /14 suites**, **416 compiled public tests /15 suites** + (including actual advertised schemas and combined CLI/MCP registrations), + **23 real SDK cases**, uncached typecheck/lint and exact CI static passed. +- Complete versus selected F-Droid task, same answer and complete parent: + **5,243 → 4,227 tokenx estimates**, + **3.616 → 3.799s** elapsed, + **4.389 → 4.825 CPU s**, + **709,554,176 → 717,103,104 bytes** peak family RSS. + These fresh-process observations use warm OS caches; used input declarations + and every call/reply/export are counted, with full-catalog estimates in JSON. +- The earlier three-provider memory comparison remains pinned to35a9b13; + it is not a new measurement of this integration. No selection bypasses hashing, + and no cryptographic authenticity or statistical speedup is claimed. + +JSON `latest_main_integration` records exact commands and input receipts. +The earlier sections retain their original commit-specific evidence. +Current-head CI and review follow publication. + +## Review follow-up qualification at35a9b13 + +That review runtime is `35a9b13adaaccff109125664898d99886d76d959`, tree `db41f5fbee2d1dca69544c221324daa8238b8e8b`. +The original qualification below remains historical at `f85a887`; JSON +`review_followup` records the new commands, outcomes and costs. + +- **216 source tests /13 suites**, **394 compiled boundary tests /13 suites**, + **23 real SDK cases**, uncached typecheck/lint and exact CI static passed. + The unchanged schema contracts retain the earlier15-check qualification. +- Corrected regressions reproduce **13 assertion failures and67 passing controls** + against the published producer source. Required lineage and CMS signature + framing, present empty signer sets, full ZIP/Zip64 directory extents and + pre-aborted cancellation reasons are now handled at their owning boundaries. +- A shared FIFO admits one active APK scan and eight pending requests across + provider instances. Pending scans hold no APK bytes. Capacity, cancellation, + release recovery and close/drain have deterministic tests; production-boundary + tests use real files with a controlled descriptor pause. +- Three simultaneous scans of the same generated/re-signed180MB input: + **981,049,344 → 448,929,792 bytes** + peak process-family RSS, **2.355 → 2.257s** elapsed, + **3.116 → 2.491 CPU s**. Every result matches the + same source SHA, SDK signer fingerprints and declared content digests. +- Current complete versus selected F-Droid SDK task, same answer and complete + parent: **5,243 → 4,227 tokenx estimates**, + **3.564 → 3.824s** elapsed, + **4.289 → 4.271 CPU s**, + **754,368,512 → 762,630,144 bytes** peak RSS. + +These are single fresh-process observations with warm OS caches. Queueing bounds +live materialization; it does not make selection skip hashing or establish a +statistical speedup. Earlier failed fixture probes, lint and library preparation +checks are retained in the JSON record. Current-head CI/review follows publication. + +## Original qualification + +Issue [#4537](https://github.com/morluto/rea/issues/4537), existing PR +[#4158](https://github.com/morluto/rea/pull/4158). Runtime `f85a887a05fa62d0675139746dbce6eba7548aee`, +tree `ff1f42613847e198988bf84dfb2d50d578084bdb`, integrates main `a4a548718d696d7329ffc7b9caa64ee72a215461` while retaining +contributor head `76445a83c8e6542131f7b38290ec749b3a199ba4`. The [JSON record](apk-signature-2026-10-22.json) +pins commands, inputs, resource profiles, failed controls and limits. + +The selected-file provider owns ZIP/signing/PKCS#7 parsing and digest work. +The shared application workflow owns identity validation, cancellation and +Evidence retention. CLI/MCP use the same summary and signer/digest/lineage +projections; immutable report caches recheck parent subject identity. Selected +pages preserve identity links, source scope and unknowns after source deletion. +Complete source Evidence remains accessible and CLI compact delivery exports it. + +## Executed checks + +- **201 source tests /12 suites passed**, and uncached typecheck/lint passed. + Tests ran on the exact staged tree; commit hooks left that tree identical. +- **408 compiled public tests /14 suites passed**, including15 advertised + schema checks, both SDK input profiles, CLI/MCP parity, retained reads after + deletion, batch/field/page semantics, partial v1/lineage facts, cached subject + identity, cancellation/cleanup, stable readers and CI selection/required gates. +- The original real-toolchain verifier passed before and after: **23 + verifier-reported CLI/SDK cases**, including v1 RSA/EC, legacy v1+v2+v3, + rotation, v3.1, source stamp, tampered bytes and forged declared digests. + Forged declarations can match bytes while apksigner rejects their signature; + REA preserves that distinction. Build Tools36.1.0/platform36 were downloaded + from Google, checked against repository metadata and pinned SHA-256. +- Exact CI static checks passed. Documentation validation follows this generated + report; current-head GitHub CI and review are checked separately. + +The initial matrix had four failures and28 controls. Two missing/truncated +framing cases and one lost algorithm-field case were genuine; its universal +suffix-refusal assertion was incorrect and is withdrawn. The owner now checks complete +signer framing, matching signed/outer v3 SDK bounds and exact algorithm records. +Reports retain raw unsigned algorithm IDs, declared digest hex, signature lengths +and unknown algorithms. Real SDK signing exposed an extra empty signed-data +field accepted by Android readers. Scheme/signer/signed-data/record suffixes +now retain explicit uninterpreted counts instead of being mislabeled malformed; +required lengths and repeated v3 SDK bounds remain checked. Representation expansion is charged before report +allocation. Shared fixtures now produce full framing. No cryptographic signature +verification is claimed. + +The larger public matrix exposed two old30s test timeouts. The SDK matrix now +passes its actual advertised definition; malformed stateless inputs share one +production stdio session while representative CLI cases remain. All original +assertions and deadlines are retained. Failed preparations and superseded runs +are explicitly recorded in JSON. + +## Public artifact and resource evidence + +The unchanged official [F-Droid APK](https://f-droid.org/F-Droid.apk) is +12,569,951 bytes, SHA-256 +`19d51fad482239f6df6a99120e34513667e9a86d136dafdc4a211a68c3327b52`. SDK `apksigner verify -v --print-certs` +verified v3 and identified `43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab`; every task reproduces that +fingerprint and exports the complete parent report. URL, response metadata and +input identity are retained. Android code was never executed. + +A distinct generated stress input adds a160MiB stored asset to that APK and +re-signs it with a generated fixture key. It is +180,350,384 bytes, SHA-256 +`cf3a3936f90bb79dabe2598dc4290f8c47d10680993c054fe8cac9d391fadaaf`; apksigner verifies it. Three provider +calls per input check exact source identity, signer and content-digest equality. +Their first/repeated in-process timings are recorded separately. An additional +512MiB+1 sparse file is refused before whole-file allocation. + +Whole resource probe: **2.047 s / 2.393 CPU s / 449,503,232 bytes peak sampled family RSS**. The whole-file +reader retains chunks and a concatenated buffer; its512MiB input cap is not a +512MiB process-memory claim. Selection reduces delivery and does not skip file +SHA-256 or supported digest analysis. + +## Equivalent completed SDK task + +Task: identify every declared v3 signer fingerprint in the unchanged APK and +export complete Evidence. The current complete and selected tasks export the +same parent Evidence identity. Earlier normalized facts differ because the +original report omitted raw algorithm details; all three answers and source +SHA-256 agree. The original baseline predates the later main integration; +whole-catalog changes include other upstream tools. Only the two current tasks +share identical advertised definitions and a complete parent identity. + +| Delivery | Used full input declarations + all calls/replies, tokenx | Entire catalog + all calls/replies, tokenx | Task resources | +| ----------------- | -------------------------------------------------------: | -----------------------------------------: | ----------------------------------------------------------------- | +| Original complete | 4,156 | 1,140,214 | 3.282 s / 4.083 CPU s / 751,788,032 bytes peak sampled family RSS | +| Current complete | 5,246 | 1,157,394 | 3.612 s / 4.339 CPU s / 698,650,624 bytes peak sampled family RSS | +| Current selected | 4,230 | 1,156,378 | 3.508 s / 4.384 CPU s / 700,706,816 bytes peak sampled family RSS | + +These are estimates, not actual model tokens. Counts include actual advertisements, +arguments, complete replies and exports; initialization and JSON-RPC envelopes +are unmeasured. Processes/sessions are fresh with warm OS caches. No disk-cold +or statistically stable timing claim is made. CPU uses payload-family wait-rusage +delta; RSS samples every20ms and sums descendant processes. Heavy tasks run +serially on two CPUs with8GiB/420s guards; no guard fired. + +The added GitHub lane requires pinned SDK oracle tools and has no silent skip. +Local results establish Linux static-reader behavior. Windows/macOS APK behavior, +platform installation, authenticity, cryptographic signer/lineage validity and +v1 entry coverage remain unverified. diff --git a/package.json b/package.json index 3440f60ef6..2e41b4babf 100644 --- a/package.json +++ b/package.json @@ -301,6 +301,7 @@ "verify:wasm:package": "npm run build:cached && node scripts/verify-packaged-wasm-artifact.mjs", "verify:wasm-interface-comparison": "npm run build:cached && node scripts/verify-wasm-interface-comparison.mjs", "verify:wasm:artifact": "npm run build:cached && node scripts/verify-wasm-artifact.mjs", + "verify:apk-signature": "npm run build:cached && node scripts/verify-apk-signature.mjs", "verify:evm:interface": "npm run build:cached && node scripts/verify-evm-interface.mjs", "verify:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs", "verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs", diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index fe242c8dc6..4fcea051ad 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -332,6 +332,25 @@ export const SCOPES = { "scripts/fixtures/evm-interface.sol", "third_party/evmole/**", ], + apk_signature: [ + ".github/workflows/real-apk-signature.yml", + "src/domain/apkSignature/**", + "src/apkSignature/**", + "src/application/apkSignature/**", + "src/composition/apkSignature.ts", + "src/contracts/apkSignature/**", + "src/cli/apkSignatureCommands.ts", + "src/server/registerApkSignatureTools.ts", + "src/server/createServer.ts", + "src/domain/analysisView/apkSignatureView.ts", + "src/domain/analysisView/analysisView.ts", + "src/application/analysisView/**", + "src/cli/analysisEvidenceDelivery.ts", + "src/artifacts/**", + "scripts/verify-apk-signature.mjs", + "tests/fixtures/apkSignature/**", + "tests/acceptance/apkSignature*.test.ts", + ], javascript_recovery: [ ".github/workflows/real-javascript-recovery.yml", "src/javascript/recovery/**", diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index c23f679096..85fa303e2e 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -41,6 +41,7 @@ export const toolFamilyCatalog = (sources) => { ...sources.pythonPycContracts.PYTHON_PYC_TOOL_CONTRACTS, ...sources.erlangContracts.ERLANG_BEAM_TOOL_CONTRACTS, ...sources.wasmContracts.WASM_TOOL_CONTRACTS, + ...sources.apkSignatureContracts.APK_SIGNATURE_TOOL_CONTRACTS, ...sources.sqliteContracts.SQLITE_TOOL_CONTRACTS, ...sources.captureContracts.CAPTURE_TOOL_CONTRACTS, ...sources.signalContracts.SIGNAL_TOOL_CONTRACTS, @@ -294,6 +295,10 @@ export const providerCatalog = (sources) => { ({ kind }) => kind === "artifact-provider", ), }, + { + identity: sources.apkSignatureProvider.APK_SIGNATURE_PROVIDER_IDENTITY, + contracts: sources.apkSignatureContracts.APK_SIGNATURE_TOOL_CONTRACTS, + }, { identity: sources.evmProvider.EVMOLE_PROVIDER_IDENTITY, contracts: sources.evmContracts.EVM_TOOL_CONTRACTS, diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index b700d1c621..d137dd5525 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -63,6 +63,9 @@ const SOURCE_PATHS = { signalProvider: "dist/signals/SigrokRecordingProvider.js", wasmContracts: "dist/contracts/wasm/wasmToolContracts.js", wasmProvider: "dist/wasm/WabtRelease.js", + apkSignatureContracts: + "dist/contracts/apkSignature/apkSignatureToolContracts.js", + apkSignatureProvider: "dist/apkSignature/ApkSignatureRelease.js", evmContracts: "dist/contracts/evm/evmToolContracts.js", sqliteContracts: "dist/contracts/sqlite/sqliteToolContracts.js", sqliteDomain: "dist/domain/sqlite/sqliteDatabase.js", diff --git a/scripts/lib/test-projects.mjs b/scripts/lib/test-projects.mjs index af9e567929..36ec1874a6 100644 --- a/scripts/lib/test-projects.mjs +++ b/scripts/lib/test-projects.mjs @@ -17,7 +17,7 @@ export const SOURCE_TEST_PROJECTS = [ group: "source", include: [ "src/*.test.ts", - "src/{android,apktool,jeb,adb,artifacts,browser,config,dotnet,electron,erlang,evm,firmware,flutter,pcap,ghidra,go,gpu,hopper,ida,inspector,javascript,native,process,python,reference,rust,server,sqlite,wasm,windows}/**/*.test.ts", + "src/{android,apkSignature,apktool,jeb,adb,artifacts,browser,config,dotnet,electron,erlang,evm,firmware,flutter,pcap,ghidra,go,gpu,hopper,ida,inspector,javascript,native,process,python,reference,rust,server,sqlite,wasm,windows}/**/*.test.ts", ], }, { diff --git a/scripts/verify-apk-signature.mjs b/scripts/verify-apk-signature.mjs new file mode 100644 index 0000000000..ca763b974f --- /dev/null +++ b/scripts/verify-apk-signature.mjs @@ -0,0 +1,692 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { + access, + copyFile, + mkdtemp, + open, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { parseEvidence } from "../dist/domain/evidence.js"; +import { computeApkContentDigest } from "../dist/domain/apkSignature/apkContentDigest.js"; +import { + locateApkSigningBlock, + parseZipTail, +} from "../dist/domain/apkSignature/apkSigningBlock.js"; +import { mcpTextValue } from "./lib/mcp-verifier-results.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; + +const execute = promisify(execFile); +const environment = Object.fromEntries( + Object.entries(process.env).filter(([, v]) => typeof v === "string"), +); + +const sdkRoots = [ + environment.ANDROID_HOME, + environment.ANDROID_SDK_ROOT, + join(homedir(), "Android", "Sdk"), + join(homedir(), "Library", "Android", "sdk"), +].filter((r) => typeof r === "string" && r.length > 0); + +const newestExisting = async (root, sub, name) => { + try { + const versions = (await readdir(join(root, sub))).sort().reverse(); + for (const v of versions) { + const candidate = join(root, sub, v, name); + try { + await access(candidate, constants.R_OK); + return candidate; + } catch { + /* next */ + } + } + } catch { + /* missing */ + } + return undefined; +}; +const resolveSdk = async (sub, name) => { + for (const root of sdkRoots) { + const found = await newestExisting(root, sub, name); + if (found !== undefined) return found; + } + return undefined; +}; +const probe = async (tool, args) => { + try { + await execute(tool, args); + return true; + } catch { + return false; + } +}; + +const apksigner = await resolveSdk("build-tools", "apksigner"); +const aapt2 = await resolveSdk("build-tools", "aapt2"); +const androidJar = await resolveSdk("platforms", "android.jar"); +const keytool = (await probe("keytool", ["-help"])) ? "keytool" : undefined; +if ([apksigner, aapt2, androidJar, keytool].includes(undefined)) { + if (process.argv.includes("--require-tools")) + throw new Error( + "APK signature lane requires apksigner, aapt2, a platform android.jar and keytool.", + ); + console.log( + JSON.stringify({ + status: "skipped", + reason: + "APK signature lane requires the Android SDK (apksigner, aapt2, platforms/android.jar) and a JDK keytool.", + }), + ); + process.exit(0); +} + +const entrypoint = + process.argv.slice(2).find((arg) => !arg.startsWith("--")) ?? + fileURLToPath(new URL("./rea.mjs", import.meta.url)); + +const run = async (tool, args) => + execute(tool, args, { + env: environment, + timeout: 180_000, + maxBuffer: 64 * 1024 * 1024, + }); + +const nodeHash = (algorithm) => { + const hash = createHash(algorithm); + return { + update: (data) => { + hash.update(data); + }, + digest: () => new Uint8Array(hash.digest()), + }; +}; + +/** Change one entry byte and replace every declared content digest in the block. */ +const forgeDeclaredDigests = async (original) => { + const bytes = Buffer.from(original); + const tail = parseZipTail(bytes); + assert.ok(tail.ok); + const block = locateApkSigningBlock(bytes, tail.value); + assert.ok(block.ok && block.value !== null); + const { blockStart } = block.value; + const digests = () => + ["sha256", "sha512"].map((algorithm) => + Buffer.from( + computeApkContentDigest( + bytes, + { tail: tail.value, blockStart, algorithm }, + nodeHash, + ), + ), + ); + const before = digests(); + bytes[40] = (bytes[40] ?? 0) ^ 0xff; + const after = digests(); + let replaced = 0; + for (const [index, old] of before.entries()) + for ( + let at = bytes.indexOf(old, blockStart); + at >= 0 && at < tail.value.centralDirectoryOffset; + at = bytes.indexOf(old, at + old.length) + ) { + after[index].copy(bytes, at); + replaced += 1; + } + assert.ok(replaced > 0, "the signed data must declare a content digest"); + return bytes; +}; + +/** The SDK's own verdict, parsed from `apksigner verify -v --print-certs`. */ +const oracleVerify = async (apk) => { + try { + const { stdout } = await run(apksigner, [ + "verify", + "-v", + "--print-certs", + apk, + ]); + const scheme = (label) => + new RegExp(`Verified using ${label}[^:]*: true`).test(stdout); + return { + verifies: true, + v1: scheme("v1 scheme"), + v2: scheme("v2 scheme"), + v3: scheme("v3 scheme"), + signerSha256: + stdout.match( + /Signer #1 certificate SHA-256 digest: ([0-9a-f]{64})/, + )?.[1] ?? null, + }; + } catch { + return { verifies: false }; + } +}; +/** The JDK's view of the v1 (JAR) signer certificates, leaf first per signer. */ +const oracleJarSigners = async (apk) => { + const { stdout } = await run(keytool, ["-printcert", "-jarfile", apk]); + return [...stdout.matchAll(/SHA256: ([0-9A-F:]{95})/g)].map((m) => + m[1].replaceAll(":", "").toLowerCase(), + ); +}; +const oracleLineage = async (apk) => { + const { stdout } = await run(apksigner, [ + "lineage", + "--in", + apk, + "--print-certs", + ]); + return [ + ...stdout.matchAll( + /in lineage certificate SHA-256 digest: ([0-9a-f]{64})/g, + ), + ].map((m) => m[1]); +}; + +const inspectCli = async (path) => { + try { + return JSON.parse( + ( + await run(process.execPath, [ + entrypoint, + "inspect-apk-signature", + path, + "--format", + "json", + ]) + ).stdout, + ); + } catch (cause) { + if (typeof cause.stdout === "string" && cause.stdout.length > 0) + return JSON.parse(cause.stdout); + throw cause; + } +}; + +const verifierRun = createVerifierRun(); +const workspace = await mkdtemp(join(tmpdir(), "rea-apk-signature-verifier-")); +const client = new Client({ name: "apk-signature-verifier", version: "1" }); +const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: environment, + stderr: "pipe", +}); +let cases = 0; +const failures = []; + +try { + await client.connect(transport); + const advertised = (await client.listTools()).tools.find( + ({ name }) => name === "inspect_apk_signature", + ); + assert.ok(advertised?.outputSchema, "tool must advertise an output schema"); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + assert.ok(ajv.validateSchema(advertised.inputSchema), "input schema invalid"); + assert.ok( + ajv.validateSchema(advertised.outputSchema), + "output schema invalid", + ); + + /** CLI and MCP must return identical, schema-valid Evidence. */ + const inspectBoth = async (path) => { + const cliValue = await inspectCli(path); + assert.equal( + cliValue.category, + undefined, + JSON.stringify(cliValue).slice(0, 300), + ); + const cli = parseEvidence(cliValue); + const reply = await client.callTool( + { + name: "inspect_apk_signature", + arguments: { path, detail: "complete" }, + }, + { timeout: 120000, toolDefinition: advertised }, + ); + assert.notEqual(reply.isError, true, mcpTextValue(reply)); + assert.ok( + ajv.validate(advertised.outputSchema, reply.structuredContent), + JSON.stringify(ajv.errors), + ); + const mcp = parseEvidence(reply.structuredContent); + assert.deepEqual(cli, mcp, "CLI and MCP evidence differ"); + cases += 2; + return mcp.normalized_result; + }; + + // A real minimal APK (binary manifest via aapt2) so apksigner can verify it. + await writeFile( + join(workspace, "AndroidManifest.xml"), + ` + + + + +`, + ); + const base = join(workspace, "base.apk"); + await run(aapt2, [ + "link", + "-o", + base, + "--manifest", + join(workspace, "AndroidManifest.xml"), + "-I", + androidJar, + ]); + const key = async (name, args) => + run(keytool, [ + "-genkeypair", + "-keystore", + join(workspace, `${name}.jks`), + "-storepass", + "pass123", + "-keypass", + "pass123", + "-alias", + name, + "-validity", + "3650", + ...args, + ]); + await key("k1", [ + "-keyalg", + "RSA", + "-keysize", + "2048", + "-dname", + "CN=REA Lane One", + ]); + await key("k2", [ + "-keyalg", + "EC", + "-groupname", + "secp256r1", + "-dname", + "CN=REA Lane Two", + ]); + const ks = (name) => [ + "--ks", + join(workspace, `${name}.jks`), + "--ks-pass", + "pass:pass123", + ]; + + // 1) Default v2+v3 signing: schemes, signer fingerprint and self-consistency. + const full = join(workspace, "full.apk"); + await run(apksigner, ["sign", ...ks("k1"), "--out", full, base]); + const fullOracle = await oracleVerify(full); + const fullReport = await inspectBoth(full); + assert.equal(fullReport.schemes.v2.present, fullOracle.v2); + assert.equal(fullReport.schemes.v3.present, fullOracle.v3); + for (const scheme of ["v2", "v3"]) + assert.equal( + fullReport.schemes[scheme].signers[0].certificate.sha256_fingerprint, + fullOracle.signerSha256, + `${scheme} signer fingerprint must equal apksigner's`, + ); + assert.equal(fullReport.posture.schemes_agree_on_signer, true); + assert.equal( + fullReport.posture.self_consistent, + true, + "a freshly signed APK must be self-consistent", + ); + assert.equal(fullReport.posture.v1_only, false); + + // 2) v1-only signing (X-Android-APK-Signed is not read, so this cannot be + // told apart from a v2/v3 block that was removed). Android requires v2+ + // for targetSdk >= 30, so this case uses a legacy-target base APK. REA's + // v1.present is structural; apksigner's "verified using v1" is platform + // applicability and is false for minSdk >= 24 even when CERT.SF exists. + await writeFile( + join(workspace, "LegacyManifest.xml"), + ` + + + + +`, + ); + const legacyBase = join(workspace, "legacy-base.apk"); + await run(aapt2, [ + "link", + "-o", + legacyBase, + "--manifest", + join(workspace, "LegacyManifest.xml"), + "-I", + androidJar, + ]); + const v1only = join(workspace, "v1only.apk"); + await run(apksigner, [ + "sign", + ...ks("k1"), + "--v1-signing-enabled", + "true", + "--v2-signing-enabled", + "false", + "--v3-signing-enabled", + "false", + "--out", + v1only, + legacyBase, + ]); + const v1Oracle = await oracleVerify(v1only); + assert.equal(v1Oracle.v1, true, "oracle should see v1"); + const v1Report = await inspectBoth(v1only); + assert.equal(v1Report.schemes.v1.present, true); + assert.equal(v1Report.schemes.v2.present, false); + assert.equal(v1Report.posture.v1_only, true); + assert.equal(v1Report.posture.strongest_scheme, "v1"); + /** REA's declared v1 signer must be the certificate apksigner and keytool name. */ + const assertV1Signer = async (apk, report, expected) => { + const [signer, ...rest] = report.schemes.v1.signers; + assert.equal(rest.length, 0, "one v1 signer"); + assert.equal(signer.decode_error, null, signer.decode_error ?? ""); + assert.equal(signer.certificate?.sha256_fingerprint, expected); + assert.deepEqual( + signer.certificates.map((c) => c.sha256_fingerprint), + await oracleJarSigners(apk), + "REA v1 certificates must equal keytool -printcert -jarfile", + ); + assert.equal(signer.signer_infos[0]?.certificate_sha256, expected); + assert.ok(signer.certificate?.subject, "v1 signer subject"); + }; + await assertV1Signer(v1only, v1Report, v1Oracle.signerSha256); + assert.equal(v1Report.posture.v1_signers_match_block_signers, null); + + // 2b) v1 with an EC key: the block is CERT.EC (or the alias-named .EC). + const v1ec = join(workspace, "v1ec.apk"); + await run(apksigner, [ + "sign", + ...ks("k2"), + "--v2-signing-enabled", + "false", + "--v3-signing-enabled", + "false", + "--out", + v1ec, + legacyBase, + ]); + const v1ecOracle = await oracleVerify(v1ec); + const v1ecReport = await inspectBoth(v1ec); + assert.ok(v1ecReport.schemes.v1.signers[0].block_file.endsWith(".EC")); + await assertV1Signer(v1ec, v1ecReport, v1ecOracle.signerSha256); + + // 2c) Default signing of a legacy-target APK carries v1, v2 and v3 with one + // key: the v1 signer equals the block signers. + const legacyFull = join(workspace, "legacy-full.apk"); + await run(apksigner, ["sign", ...ks("k1"), "--out", legacyFull, legacyBase]); + const legacyOracle = await oracleVerify(legacyFull); + assert.equal(legacyOracle.v1, true, "oracle should verify v1 too"); + const legacyReport = await inspectBoth(legacyFull); + await assertV1Signer(legacyFull, legacyReport, legacyOracle.signerSha256); + assert.equal(legacyReport.posture.v1_signers_match_block_signers, true); + + // 3) Key rotation: lineage certificates and a signer change the APK declares. + const lineage = join(workspace, "lineage.bin"); + await run(apksigner, [ + "rotate", + "--out", + lineage, + "--old-signer", + ...ks("k1"), + "--new-signer", + ...ks("k2"), + ]); + const rotated = join(workspace, "rotated.apk"); + await run(apksigner, [ + "sign", + ...ks("k1"), + "--next-signer", + ...ks("k2"), + "--lineage", + lineage, + "--rotation-min-sdk-version", + "28", + "--out", + rotated, + base, + ]); + const lineageOracle = await oracleLineage(rotated); + const rotatedReport = await inspectBoth(rotated); + const reaLineage = rotatedReport.schemes.v3.signers[0].rotation.lineage.map( + (n) => n.certificate.sha256_fingerprint, + ); + assert.deepEqual( + reaLineage, + lineageOracle, + "REA lineage must equal apksigner lineage (order + digests)", + ); + // Observed: v2 (old key) and v3 (new key) differ. Declared: the lineage + // covers both. The two facts stay separate. + assert.equal(rotatedReport.posture.schemes_agree_on_signer, false); + assert.equal(rotatedReport.posture.signer_change_declared_by_rotation, true); + assert.deepEqual( + rotatedReport.schemes.v3.signers[0].rotation.lineage.map( + (n) => n.capabilities.installed_data, + ), + [true, true], + ); + + // 3a) Rotation on a legacy-target APK: v1 and v2 keep the old key, v3 the + // new one. v1 is compared with v2 (the lowest block scheme), so the + // rotation does not read as a JAR-signer mismatch. + const rotatedLegacy = join(workspace, "rotated-legacy.apk"); + await run(apksigner, [ + "sign", + ...ks("k1"), + "--next-signer", + ...ks("k2"), + "--lineage", + lineage, + "--rotation-min-sdk-version", + "28", + "--out", + rotatedLegacy, + legacyBase, + ]); + const rotatedLegacyReport = await inspectBoth(rotatedLegacy); + await assertV1Signer( + rotatedLegacy, + rotatedLegacyReport, + fullOracle.signerSha256, + ); + assert.equal(rotatedLegacyReport.posture.schemes_agree_on_signer, false); + assert.equal( + rotatedLegacyReport.posture.v1_signers_match_block_signers, + true, + ); + + // 3a') The same rotation without v2: v1 keeps the original key and v3 signs + // with the new one. apksig requires the v1 signer to be the lineage's + // first node, so v1 is compared with that node, not the v3 signer. + const rotatedNoV2 = join(workspace, "rotated-legacy-no-v2.apk"); + await run(apksigner, [ + "sign", + ...ks("k1"), + "--next-signer", + ...ks("k2"), + "--lineage", + lineage, + "--rotation-min-sdk-version", + "28", + "--v2-signing-enabled", + "false", + "--out", + rotatedNoV2, + legacyBase, + ]); + const noV2Oracle = await oracleVerify(rotatedNoV2); + assert.equal(noV2Oracle.verifies, true, "oracle must accept v1 + rotated v3"); + assert.equal(noV2Oracle.v1, true); + assert.equal(noV2Oracle.v2, false); + const noV2Report = await inspectBoth(rotatedNoV2); + assert.equal(noV2Report.schemes.v2.present, false); + await assertV1Signer(rotatedNoV2, noV2Report, fullOracle.signerSha256); + assert.deepEqual( + noV2Report.schemes.v3.signers[0].rotation.lineage.map( + (n) => n.certificate.sha256_fingerprint, + ), + await oracleLineage(rotatedNoV2), + ); + assert.equal( + noV2Report.posture.v1_signers_match_block_signers, + true, + "a v1 signer equal to the lineage origin is the expected rotation layout", + ); + + // 3b) Rotation targeting SDK 33 is carried by v3.1; v3 keeps the original signer. + const rotated31 = join(workspace, "rotated31.apk"); + await run(apksigner, [ + "sign", + ...ks("k1"), + "--next-signer", + ...ks("k2"), + "--lineage", + lineage, + "--rotation-min-sdk-version", + "33", + "--out", + rotated31, + base, + ]); + assert.equal((await oracleVerify(rotated31)).verifies, true); + const rotated31Report = await inspectBoth(rotated31); + assert.equal(rotated31Report.schemes["v3.1"].present, true); + assert.equal(rotated31Report.posture.strongest_scheme, "v3.1"); + const v31Signer = rotated31Report.schemes["v3.1"].signers[0]; + assert.equal(v31Signer.min_sdk, 33); + assert.deepEqual( + v31Signer.rotation.lineage.map((n) => n.certificate.sha256_fingerprint), + lineageOracle, + "v3.1 lineage must equal the apksigner lineage", + ); + assert.equal( + rotated31Report.schemes.v3.signers[0].certificate.sha256_fingerprint, + fullOracle.signerSha256, + "v3 keeps the original signer below the v3.1 SDK range", + ); + assert.equal(rotated31Report.posture.self_consistent, true); + assert.equal(rotated31Report.posture.schemes_agree_on_signer, false); + assert.equal( + rotated31Report.posture.signer_change_declared_by_rotation, + true, + ); + + // 3c) Source stamp (apksigner requires an RSA stamp key). + const stamped = join(workspace, "stamped.apk"); + await run(apksigner, [ + "sign", + ...ks("k1"), + "--stamp-signer", + ...ks("k1"), + "--out", + stamped, + base, + ]); + const stampedReport = await inspectBoth(stamped); + assert.equal(stampedReport.source_stamp.present, true); + assert.equal(stampedReport.posture.source_stamp_present, true); + assert.equal(stampedReport.posture.self_consistent, true); + assert.equal(stampedReport.source_stamp.decode_error, null); + assert.equal( + stampedReport.source_stamp.certificate?.sha256_fingerprint, + fullOracle.signerSha256, + "REA must read the declared stamp certificate (k1)", + ); + + // 4) One changed entry byte with the declared digest left alone: apksigner + // rejects it and the declared digest no longer matches the bytes. + const tampered = join(workspace, "tampered.apk"); + await copyFile(full, tampered); + const handle = await open(tampered, "r+"); + const byte = Buffer.alloc(1); + await handle.read(byte, 0, 1, 40); + byte[0] = (byte[0] ?? 0) ^ 0xff; + await handle.write(byte, 0, 1, 40); + await handle.close(); + assert.equal( + (await oracleVerify(tampered)).verifies, + false, + "oracle must reject the tampered APK", + ); + const tamperedReport = await inspectBoth(tampered); + assert.equal( + tamperedReport.posture.self_consistent, + false, + "REA must report the tampered APK as not self-consistent", + ); + + // 4b) The same byte change with the declared digests recomputed and written + // back: apksigner still rejects it (the signature no longer verifies), + // while the declared digests match the bytes. self_consistent is a + // digest comparison, not a signature verification. + const forged = join(workspace, "forged-digest.apk"); + await writeFile(forged, await forgeDeclaredDigests(await readFile(full))); + assert.equal( + (await oracleVerify(forged)).verifies, + false, + "oracle must reject the APK with recomputed declared digests", + ); + const forgedReport = await inspectBoth(forged); + assert.equal( + forgedReport.posture.self_consistent, + true, + "a recomputed declared digest matches the bytes without a valid signature", + ); + + // 5) Error surface: a non-ZIP file is an unsupported target, not a crash. + const notZip = join(workspace, "not-a-zip.apk"); + await writeFile(notZip, "definitely not a zip archive"); + const refused = await inspectCli(notZip); + assert.equal( + refused.category, + "unsupported_target", + JSON.stringify(refused).slice(0, 300), + ); + cases += 1; +} catch (cause) { + failures.push(cause); +} + +for (const close of [ + () => client.close(), + () => transport.close(), + () => rm(workspace, { recursive: true, force: true }), +]) { + try { + await close(); + } catch (cause) { + failures.push(cause); + } +} + +if (failures.length > 0) + throw new AggregateError(failures, "APK signature verification failed."); +console.log( + JSON.stringify( + { + status: "passed", + cases, + toolchain: "android-apksigner-aapt2", + entrypoint, + verifier: await completeVerifierRun(verifierRun), + }, + null, + 2, + ), +); diff --git a/src/apkSignature/ApkSignatureAdmission.test.ts b/src/apkSignature/ApkSignatureAdmission.test.ts new file mode 100644 index 0000000000..0c99c91aae --- /dev/null +++ b/src/apkSignature/ApkSignatureAdmission.test.ts @@ -0,0 +1,42 @@ +import { expect, it } from "vitest"; +import { apkSignatureAdmission } from "./ApkSignatureAdmission.js"; +import { APK_SIGNATURE_LIMITS } from "./ApkSignatureRelease.js"; + +it("bounds process-wide scans and waiting requests, cancels queued work and recovers capacity", async () => { + const active = await apkSignatureAdmission.acquire(); + const controller = new AbortController(); + const reason = new Error("cancel queued scan"); + let granted = false; + const first = apkSignatureAdmission.acquire().then((release) => { + granted = true; + return release; + }); + const cancelled = apkSignatureAdmission.acquire(controller.signal); + const rejection = expect(cancelled).rejects.toMatchObject({ + _tag: "AnalysisCancelledError", + cause: reason, + }); + const waiting = Array.from( + { length: APK_SIGNATURE_LIMITS.waitingScans - 2 }, + () => apkSignatureAdmission.acquire(), + ); + try { + await expect(apkSignatureAdmission.acquire()).rejects.toMatchObject({ + _tag: "AnalysisResourceConstraintError", + resource: "capacity", + }); + expect(granted).toBe(false); + controller.abort(reason); + await rejection; + const replacement = apkSignatureAdmission.acquire(); + active(); + active(); + const releaseFirst = await first; + releaseFirst(); + for (const queued of waiting) (await queued)(); + (await replacement)(); + (await apkSignatureAdmission.acquire())(); + } finally { + active(); + } +}); diff --git a/src/apkSignature/ApkSignatureAdmission.ts b/src/apkSignature/ApkSignatureAdmission.ts new file mode 100644 index 0000000000..dffa7bc9ca --- /dev/null +++ b/src/apkSignature/ApkSignatureAdmission.ts @@ -0,0 +1,65 @@ +import { + AnalysisCancelledError, + AnalysisResourceConstraintError, +} from "../domain/analysisErrorCore.js"; +import { APK_SIGNATURE_LIMITS } from "./ApkSignatureRelease.js"; + +const OPERATION = "inspect_apk_signature"; +type Release = () => void; + +/** One whole-file scan at a time, shared by every provider in this process. */ +class ApkSignatureAdmission { + #active = false; + readonly #waiting: (() => void)[] = []; + + acquire(signal?: AbortSignal): Promise { + if (signal?.aborted) + return Promise.reject( + new AnalysisCancelledError(OPERATION, { cause: signal.reason }), + ); + if (!this.#active) { + this.#active = true; + return Promise.resolve(this.#release()); + } + if (this.#waiting.length >= APK_SIGNATURE_LIMITS.waitingScans) + return Promise.reject( + new AnalysisResourceConstraintError( + OPERATION, + "capacity", + "The process-wide APK inspection queue is full; retry after an active scan finishes.", + { + max_active_scans: 1, + max_waiting_scans: APK_SIGNATURE_LIMITS.waitingScans, + }, + ), + ); + return new Promise((resolve, reject) => { + const grant = () => { + signal?.removeEventListener("abort", onAbort); + resolve(this.#release()); + }; + const onAbort = () => { + const index = this.#waiting.indexOf(grant); + if (index !== -1) this.#waiting.splice(index, 1); + reject( + new AnalysisCancelledError(OPERATION, { cause: signal?.reason }), + ); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + this.#waiting.push(grant); + }); + } + + #release(): Release { + let released = false; + return () => { + if (released) return; + released = true; + const grant = this.#waiting.shift(); + if (grant === undefined) this.#active = false; + else grant(); + }; + } +} + +export const apkSignatureAdmission = new ApkSignatureAdmission(); diff --git a/src/apkSignature/ApkSignatureProvider.test.ts b/src/apkSignature/ApkSignatureProvider.test.ts new file mode 100644 index 0000000000..17e2b08cce --- /dev/null +++ b/src/apkSignature/ApkSignatureProvider.test.ts @@ -0,0 +1,660 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { + APK_SIGNATURE_V2_ID, + APK_SIGNATURE_V3_ID, + LEAF_CERTIFICATE, + LEAF_SHA256, + LEAF_SUBJECT, + OID_ECDSA_SHA256, + OID_SHA256, + OPENSSL_BLOCK_ISSUER_SERIAL, + ROOT_CERTIFICATE, + ROOT_SHA256, + signedDataBlock, + signerInfo, + v2SchemeValue, + withSigningBlock, + zipWith, +} from "../../tests/fixtures/apkSignature/v1SignatureBlock.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { ApkSignatureProvider } from "./ApkSignatureProvider.js"; +import { apkSignatureFailure } from "./apkSignatureFailures.js"; +import { apkSchemeValue } from "../../tests/fixtures/apkSignature/signingScheme.js"; + +type V1Signer = { + signature_file: string; + block_file: string; + certificate: { sha256_fingerprint: string; subject: string | null } | null; + certificates: { sha256_fingerprint: string; subject: string | null }[]; + signer_infos: Record[]; + decode_error: string | null; +}; +type Report = { + schemes: { v1: { present: boolean; signers: V1Signer[] } } & Record< + string, + { + present: boolean; + signers?: { + certificate: unknown; + digests: { algorithm: string; matches_apk: boolean | null }[]; + }[]; + } + >; + posture: Record; + source_stamp: { + present: boolean; + certificate: { sha256_fingerprint: string } | null; + decode_error: string | null; + }; +}; + +const zipOf = async (names: readonly string[]): Promise => { + const writer = new ZipWriter(new Uint8ArrayWriter()); + for (const name of names) await writer.add(name, new TextReader("x")); + return writer.close(); +}; + +const u16 = (v: number): number[] => [v & 0xff, (v >> 8) & 0xff]; +const u32 = (v: number): number[] => [ + v & 0xff, + (v >> 8) & 0xff, + (v >> 16) & 0xff, + (v >>> 24) & 0xff, +]; +const u64 = (v: number): number[] => [...u32(v), ...u32(0)]; +const lp = (b: readonly number[]): number[] => [...u32(b.length), ...b]; +const seq = (els: readonly (readonly number[])[]): number[] => + lp(els.flatMap((e) => lp(e))); + +/** [entries][signing block with the given pairs][central directory][EOCD]. */ +const syntheticSigned = ( + pairs: readonly { readonly id: number; readonly value: Uint8Array }[], + options: { + corruptLeadingSize?: boolean; + cdEntries?: number; + gapBeforeEocd?: number; + } = {}, +): Uint8Array => { + const entries = Buffer.from(Array.from({ length: 64 }, (_, i) => i & 0xff)); + const framed = Buffer.concat( + pairs.flatMap((p) => [ + Buffer.from([...u64(4 + p.value.length), ...u32(p.id)]), + p.value, + ]), + ); + const size = framed.length + 8 + 16; + const block = Buffer.concat([ + Buffer.from(u64(size)), + framed, + Buffer.from(u64(size)), + Buffer.from("APK Sig Block 42", "latin1"), + ]); + if (options.corruptLeadingSize) block[0] = 0xff; + // An empty central directory unless a test declares (bogus) records in it. + const cd = + options.cdEntries === undefined + ? Buffer.alloc(0) + : Buffer.from([0x50, 0x4b, 0x01, 0x02, 0, 0, 0, 0]); + const gap = Buffer.alloc(options.gapBeforeEocd ?? 0); + const cdOffset = entries.length + block.length; + const records = options.cdEntries ?? 0; + const eocd = Buffer.from([ + ...u32(0x06054b50), + ...u16(0), + ...u16(0), + ...u16(records), + ...u16(records), + ...u32(cd.length), + ...u32(cdOffset), + ...u16(0), + ]); + return Uint8Array.from(Buffer.concat([entries, block, cd, gap, eocd])); +}; + +const V2 = 0x7109871a; +const STAMP_V2 = 0x6dff800d; +const V31 = 0x1b93ad61; + +const BOGUS_DIGEST = [ + ...u32(0x0103), + ...lp(Array.from({ length: 32 }, () => 0xab)), +]; +const BOGUS_CERT = [0x30, 0x03, 1, 2, 3]; + +/** A v2 value with one signer: bogus certificate bytes and a bogus SHA-256 digest. */ +const bogusV2Value = (): Uint8Array => + apkSchemeValue( + Uint8Array.from([...seq([BOGUS_DIGEST]), ...seq([BOGUS_CERT]), ...seq([])]), + ); + +/** A v3-layout value whose one signer carries the given attributes. */ +const v3Value = (attributes: readonly (readonly number[])[]): Uint8Array => + apkSchemeValue( + Uint8Array.from([ + ...seq([BOGUS_DIGEST]), + ...seq([BOGUS_CERT]), + ...u32(28), + ...u32(0x7fffffff), + ...seq(attributes), + ]), + { min: 28, max: 0x7fffffff }, + ); + +/** Length-prefix a large buffer without spreading it into a number array. */ +const lpBuf = (bytes: Uint8Array): Buffer => + Buffer.concat([Buffer.from(u32(bytes.length)), bytes]); + +let dir: string; +const provider = new ApkSignatureProvider(); +beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), "rea-apk-sig-")); +}); +afterAll(async () => { + await provider.close(); + await rm(dir, { recursive: true, force: true }); +}); +const write = async ( + name: string, + bytes: Uint8Array | string, +): Promise => { + const path = join(dir, name); + await writeFile(path, bytes); + return path; +}; +const reportOf = async (path: string): Promise => { + const result = await provider.inspect({ path }); + if (!result.ok) throw new Error(`expected ok: ${result.error.message}`); + return result.value.result as Report; +}; + +describe("ApkSignatureProvider without the Android SDK", () => { + it("retains the declared digest's signature algorithm ID and bytes", async () => { + const report = await reportOf( + await write( + "algorithm-id.apk", + syntheticSigned([{ id: V2, value: bogusV2Value() }]), + ), + ); + expect(report.schemes.v2?.signers?.[0]?.digests[0]).toMatchObject({ + signature_algorithm_id: 0x0103, + declared_digest_hex: "ab".repeat(32), + }); + }); + it("reports an unsigned ZIP as having no scheme", async () => { + const r = await reportOf( + await write( + "unsigned.apk", + await zipOf(["classes.dex", "AndroidManifest.xml"]), + ), + ); + expect(Object.values(r.schemes).some((s) => s.present)).toBe(false); + expect(r.posture.strongest_scheme).toBe("none"); + expect(r.posture.self_consistent).toBeNull(); + expect(r.posture.v1_only).toBe(false); + }); + + it("detects v1-only signing and names the signer certificate its block declares", async () => { + for (const stored of [false, true]) { + const r = await reportOf( + await write( + `v1-${String(stored)}.apk`, + await zipWith([ + { name: "classes.dex", data: "x" }, + { name: "META-INF/MANIFEST.MF", data: "Manifest-Version: 1.0" }, + { name: "META-INF/CERT.SF", data: "Signature-Version: 1.0" }, + { + name: "META-INF/CERT.EC", + data: OPENSSL_BLOCK_ISSUER_SERIAL, + stored, + }, + ]), + ), + ); + expect(r.schemes.v1.present).toBe(true); + expect(r.posture.v1_only).toBe(true); + expect(r.posture.strongest_scheme).toBe("v1"); + expect(r.posture.v1_signers_match_block_signers).toBeNull(); + const [signer] = r.schemes.v1.signers; + expect(signer).toMatchObject({ + signature_file: "META-INF/CERT.SF", + block_file: "META-INF/CERT.EC", + certificate: { + sha256_fingerprint: LEAF_SHA256, + subject: LEAF_SUBJECT, + public_key_algorithm: "ec", + }, + certificates: [ + { sha256_fingerprint: LEAF_SHA256, subject: LEAF_SUBJECT }, + { sha256_fingerprint: ROOT_SHA256 }, + ], + signer_infos: [ + { + identified_by: "issuer-and-serial", + certificate_sha256: LEAF_SHA256, + digest_algorithm_oid: OID_SHA256, + signature_algorithm_oid: OID_ECDSA_SHA256, + }, + ], + decode_error: null, + }); + } + }); + + it("decodes a v2 signer, keeps an unparseable certificate null, and flags a digest mismatch", async () => { + const r = await reportOf( + await write( + "v2.apk", + syntheticSigned([{ id: V2, value: bogusV2Value() }]), + ), + ); + expect(r.schemes.v2!.present).toBe(true); + const signer = r.schemes.v2!.signers![0]!; + // Identity is the raw-DER fingerprint even when X.509 parsing fails. + expect(signer.certificate).toEqual({ + sha256_fingerprint: createHash("sha256") + .update(Uint8Array.from(BOGUS_CERT)) + .digest("hex"), + subject: null, + issuer: null, + serial_number: null, + not_before: null, + not_after: null, + public_key_algorithm: null, + }); + expect(signer.digests).toEqual([ + { + signature_algorithm_id: 0x0103, + uninterpreted_bytes: 0, + algorithm: "sha256", + declared_digest_hex: "ab".repeat(32), + matches_apk: false, + }, + ]); + expect(r.posture.self_consistent).toBe(false); + }); +}); + +describe("ApkSignatureProvider declared facts", () => { + it("reads the declared source-stamp certificate, or says why it could not", async () => { + const stamp = Uint8Array.from(lp([...lp(BOGUS_CERT), ...seq([])])); + const read = await reportOf( + await write( + "stamped.apk", + syntheticSigned([ + { id: V2, value: bogusV2Value() }, + { id: STAMP_V2, value: stamp }, + ]), + ), + ); + expect(read.source_stamp).toMatchObject({ + present: true, + certificate: { + sha256_fingerprint: createHash("sha256") + .update(Uint8Array.from(BOGUS_CERT)) + .digest("hex"), + }, + decode_error: null, + }); + + const damaged = await reportOf( + await write( + "stamp-damaged.apk", + syntheticSigned([ + { id: V2, value: bogusV2Value() }, + { id: STAMP_V2, value: Uint8Array.from(u32(64)) }, + ]), + ), + ); + expect(damaged.source_stamp).toEqual({ + present: true, + certificate: null, + decode_error: "source stamp data truncated", + }); + }); + + it("reads v1 signing in META-INF subdirectories only for paired blocks", async () => { + const nested = await reportOf( + await write( + "v1-nested.apk", + await zipOf(["META-INF/a/CERT.SF", "META-INF/a/CERT.RSA"]), + ), + ); + expect(nested.schemes.v1.present).toBe(true); + expect(nested.schemes.v1.signers).toMatchObject([ + { + signature_file: "META-INF/a/CERT.SF", + block_file: "META-INF/a/CERT.RSA", + }, + ]); + const unpaired = await reportOf( + await write( + "v1-unpaired.apk", + await zipOf(["META-INF/RSA1.SF", "META-INF/OTHER.RSA"]), + ), + ); + expect(unpaired.schemes.v1.present).toBe(false); + expect(unpaired.schemes.v1.signers).toEqual([]); + expect(unpaired.posture.v1_only).toBe(false); + }); +}); + +describe("ApkSignatureProvider v1 signers", () => { + it("compares the v1 signer with the v2 signer and reports a mismatch", async () => { + const v1Zip = await zipWith([ + { name: "META-INF/CERT.SF", data: "Signature-Version: 1.0" }, + { name: "META-INF/CERT.EC", data: OPENSSL_BLOCK_ISSUER_SERIAL }, + ]); + const same = await reportOf( + await write( + "v1-v2-same.apk", + withSigningBlock(v1Zip, [ + { id: APK_SIGNATURE_V2_ID, value: v2SchemeValue(LEAF_CERTIFICATE) }, + ]), + ), + ); + expect(same.schemes.v1.signers[0]?.certificate?.sha256_fingerprint).toBe( + LEAF_SHA256, + ); + expect(same.posture.v1_signers_match_block_signers).toBe(true); + const other = await reportOf( + await write( + "v1-v2-other.apk", + withSigningBlock(v1Zip, [ + { id: APK_SIGNATURE_V2_ID, value: v2SchemeValue(ROOT_CERTIFICATE) }, + ]), + ), + ); + expect(other.posture.v1_signers_match_block_signers).toBe(false); + }); + + it("says why the v1 comparison is unknown when the v3 lineage cannot be decoded", async () => { + const v1Zip = await zipWith([ + { name: "META-INF/CERT.SF", data: "Signature-Version: 1.0" }, + { name: "META-INF/CERT.EC", data: OPENSSL_BLOCK_ISSUER_SERIAL }, + ]); + const lineage = [...u32(0x3ba06f8c), ...u32(1), ...u32(999)]; + const result = await provider.inspect({ + path: await write( + "v1-v3-undecoded-lineage.apk", + withSigningBlock(v1Zip, [ + { id: APK_SIGNATURE_V3_ID, value: v3Value([lineage]) }, + ]), + ), + }); + if (!result.ok) throw new Error(result.error.message); + const report = result.value.result as Report; + expect(report.posture.v1_signers_match_block_signers).toBeNull(); + expect(result.value.limitations).toContain( + "v1_signers_match_block_signers is null: the v3 signer's declared rotation lineage could not be decoded, so the original certificate v1 is expected to carry is unknown.", + ); + }); + + it("keeps a damaged or ambiguous v1 block as an explicit unknown", async () => { + const twoSigners = signedDataBlock({ + certificates: [LEAF_CERTIFICATE, ROOT_CERTIFICATE], + signerInfos: [ + signerInfo({ names: LEAF_CERTIFICATE }), + signerInfo({ names: ROOT_CERTIFICATE }), + ], + }); + const r = await reportOf( + await write( + "v1-damaged.apk", + withSigningBlock( + await zipWith([ + { name: "META-INF/A.SF", data: "Signature-Version: 1.0" }, + { name: "META-INF/A.RSA", data: "not pkcs7" }, + { name: "META-INF/B.SF", data: "Signature-Version: 1.0" }, + { name: "META-INF/B.EC", data: twoSigners }, + { name: "META-INF/C.SF", data: "Signature-Version: 1.0" }, + { + name: "META-INF/C.DSA", + data: new Uint8Array(2 * 1024 * 1024), + }, + ]), + [{ id: APK_SIGNATURE_V2_ID, value: v2SchemeValue(LEAF_CERTIFICATE) }], + ), + ), + ); + const [damaged, ambiguous, oversized] = r.schemes.v1.signers; + expect(damaged).toMatchObject({ + block_file: "META-INF/A.RSA", + certificate: null, + certificates: [], + decode_error: expect.stringContaining("malformed PKCS#7") as string, + }); + // Two SignerInfos and nothing verified: no single signer certificate. + expect(ambiguous?.certificate).toBeNull(); + expect(ambiguous?.signer_infos.map((i) => i.certificate_sha256)).toEqual([ + LEAF_SHA256, + ROOT_SHA256, + ]); + expect(oversized?.decode_error).toContain("over the 1048576-byte bound"); + expect(r.posture.v1_signers_match_block_signers).toBeNull(); + }); + + it("bounds inflation by the per-block limit, not by the declared size", async () => { + const zip = Buffer.from( + await zipWith([ + { name: "META-INF/D.SF", data: "x" }, + { name: "META-INF/D.RSA", data: new Uint8Array(2 * 1024 * 1024) }, + { name: "META-INF/E.SF", data: "x" }, + { name: "META-INF/E.RSA", data: OPENSSL_BLOCK_ISSUER_SERIAL }, + ]), + ); + // Under-declare both uncompressed sizes in the central directory. + const cdOffset = zip.readUInt32LE(zip.length - 22 + 16); + for (const name of ["META-INF/D.RSA", "META-INF/E.RSA"]) { + const at = zip.indexOf(Buffer.from(name), cdOffset) - 46; + zip.writeUInt32LE(100, at + 24); + } + const r = await reportOf(await write("v1-bomb.apk", zip)); + expect(r.schemes.v1.signers.map((signer) => signer.decode_error)).toEqual([ + "signature block inflates past the 1048576-byte bound", + `signature block holds ${String(OPENSSL_BLOCK_ISSUER_SERIAL.length)} bytes, the central directory declares 100`, + ]); + }); + + it("refuses more v1 signers than a signed archive carries", async () => { + const names = Array.from({ length: 33 }, (_, i) => [ + { name: `META-INF/S${String(i)}.SF`, data: "x" }, + { name: `META-INF/S${String(i)}.RSA`, data: "x" }, + ]).flat(); + const result = await provider.inspect({ + path: await write("v1-many.apk", await zipWith(names)), + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error._tag).toBe("AnalysisResourceConstraintError"); + expect(result.error.message).toContain("more than 32 v1 signature blocks"); + }); +}); + +describe("ApkSignatureProvider failure surfaces", () => { + it("keeps only a non-regular path as input; reader I/O and defects are adapter errors", () => { + const tagOf = (cause: unknown): string => + apkSignatureFailure(cause, "/x.apk")._tag; + expect( + tagOf(new ArtifactReaderFailure("path", "Expected a regular file")), + ).toBe("AnalysisInputError"); + for (const reason of ["io", "unavailable", "format"] as const) + expect(tagOf(new ArtifactReaderFailure(reason, "reader failed"))).toBe( + "ProviderAdapterError", + ); + expect(tagOf(new RangeError("boom"))).toBe("ProviderAdapterError"); + }); + + it("reports a missing file without leaking the raw errno text", async () => { + const result = await provider.inspect({ path: join(dir, "absent.apk") }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error._tag).toBe("AnalysisInputError"); + const text = JSON.stringify(result.error); + expect(text).toContain("No regular file can be read"); + expect(text).not.toContain("no such file or directory, lstat"); + }); + + it("refuses a non-ZIP file as an unsupported target", async () => { + const result = await provider.inspect({ + path: await write("plain.bin", "not an archive at all"), + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error._tag).toBe("AnalysisUnsupportedTargetError"); + }); + + it("reports a malformed signing block as an input error", async () => { + const result = await provider.inspect({ + path: await write( + "bad.apk", + syntheticSigned([{ id: V2, value: bogusV2Value() }], { + corruptLeadingSize: true, + }), + ), + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error._tag).toBe("AnalysisInputError"); + }); + + it("reports a malformed declared lineage instead of dropping it", async () => { + const lineage = [...u32(0x3ba06f8c), ...u32(1), ...u32(999)]; + const r = (await reportOf( + await write( + "lineage.apk", + syntheticSigned([{ id: V31, value: v3Value([lineage]) }]), + ), + )) as unknown as { + schemes: { "v3.1": { signers: { rotation: unknown }[] } }; + posture: { strongest_scheme: string }; + }; + expect(r.posture.strongest_scheme).toBe("v3.1"); + expect(r.schemes["v3.1"].signers[0]!.rotation).toEqual({ + present: true, + lineage: [], + decode_error: "lineage node truncated", + }); + }); + + it("names v3.1 when its block is malformed", async () => { + const result = await provider.inspect({ + path: await write( + "bad31.apk", + syntheticSigned([{ id: V31, value: Uint8Array.from(u32(77)) }]), + ), + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(JSON.stringify(result.error)).toContain("Scheme v3.1 block"); + }); + + it("refuses a central directory that cannot be walked", async () => { + const result = await provider.inspect({ + path: await write( + "badcd.apk", + syntheticSigned([{ id: V2, value: bogusV2Value() }], { cdEntries: 3 }), + ), + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error._tag).toBe("AnalysisInputError"); + expect(JSON.stringify(result.error)).toContain( + "Malformed ZIP central directory", + ); + }); + + it("refuses an under-declared entry count rather than hiding v1 signing", async () => { + const zip = await zipOf([ + "classes.dex", + "META-INF/MANIFEST.MF", + "META-INF/CERT.SF", + "META-INF/CERT.RSA", + ]); + const eocd = zip.length - 22; + zip.set(u16(1), eocd + 8); + zip.set(u16(1), eocd + 10); + const result = await provider.inspect({ + path: await write("undercount.apk", zip), + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error._tag).toBe("AnalysisInputError"); + }); + + it("says why the digest was not recomputed when the EOCD does not follow the directory", async () => { + const result = await provider.inspect({ + path: await write( + "gap.apk", + syntheticSigned([{ id: V2, value: bogusV2Value() }], { + gapBeforeEocd: 4, + }), + ), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect((result.value.result as Report).posture.self_consistent).toBeNull(); + expect(result.value.limitations.at(-1)).toContain( + "not immediately followed by the end-of-central-directory", + ); + }); + + it("refuses a report that would exceed the output budget before building it", async () => { + const hugeCert = new Uint8Array(4.5 * 1024 * 1024); + const signedData = Buffer.concat([ + Buffer.from(seq([BOGUS_DIGEST])), + lpBuf(lpBuf(hugeCert)), + Buffer.from(seq([])), + ]); + const value = apkSchemeValue(signedData); + const result = await provider.inspect({ + path: await write("huge.apk", syntheticSigned([{ id: V2, value }])), + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error._tag).toBe("AnalysisResourceConstraintError"); + expect(result.error.message).toContain("output budget"); + }); +}); + +describe("ApkSignatureProvider output budget", () => { + it("counts the limitations, which repeat archive names, in the output budget", async () => { + // Five signers whose names escape to 6 JSON bytes per byte: the report + // alone fits, and the trailing-bytes note per signer, which repeats the + // block name, takes the emitted output past the budget. + const archive = (trailing: number): Promise => + zipWith( + Array.from({ length: 5 }, (_, i) => { + const stem = `META-INF/${"\u0001".repeat(65_000)}${String(i)}`; + return [ + { name: `${stem}.SF`, data: "Signature-Version: 1.0" }, + { + name: `${stem}.RSA`, + data: Uint8Array.from([ + ...OPENSSL_BLOCK_ISSUER_SERIAL, + ...new Array(trailing).fill(0), + ]), + }, + ]; + }).flat(), + { dataDescriptor: false }, + ); + const fits = await provider.inspect({ + path: await write("names.apk", await archive(0)), + }); + expect(fits.ok, fits.ok ? "" : fits.error.message).toBe(true); + const over = await provider.inspect({ + path: await write("names-noted.apk", await archive(2)), + }); + expect(over.ok).toBe(false); + if (over.ok) return; + expect(over.error._tag).toBe("AnalysisResourceConstraintError"); + expect(over.error.message).toContain("output budget"); + }); +}); diff --git a/src/apkSignature/ApkSignatureProvider.ts b/src/apkSignature/ApkSignatureProvider.ts new file mode 100644 index 0000000000..288e07c81c --- /dev/null +++ b/src/apkSignature/ApkSignatureProvider.ts @@ -0,0 +1,622 @@ +import { createHash } from "node:crypto"; +import { apkSignatureAdmission } from "./ApkSignatureAdmission.js"; + +import { createAnalysisExecution } from "../application/AnalysisProvider.js"; +import type { ExecutionOptions } from "../application/AnalysisProvider.js"; +import type { + ApkSignatureExecution, + ApkSignaturePort, +} from "../application/apkSignature/ApkSignaturePort.js"; +import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + assertArtifactReadActive, + bindArtifactFileCleanup, +} from "../artifacts/ArtifactFileOperation.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + APK_BLOCK_ID, + locateApkSigningBlock, + parseZipTail, + type ApkSigningBlock, + type ZipTail, +} from "../domain/apkSignature/apkSigningBlock.js"; +import { + contentDigestAlgorithm, + decodeRotationLineage, + decodeSigners, + decodeSourceStampCertificate, + PROOF_OF_ROTATION_ATTR_ID, + type ApkLineageNode, + type ApkSchemeError, + type ApkSchemeVersion, + type ApkSigner, +} from "../domain/apkSignature/apkSignatureSchemes.js"; +import { + APK_SIGNATURE_LIMITATIONS, + deriveApkPosture, + apkSignatureUninterpretedBytes, + type ApkPostureSigner, + type ApkSignatureResult, +} from "../domain/apkSignature/apkSignatureReport.js"; +import { + computeApkContentDigest, + type HashFactory, +} from "../domain/apkSignature/apkContentDigest.js"; +import { + centralDirectoryV1SignatureEntries, + detectV1Signature, + MAX_V1_SIGNERS, + type ApkDuplicateName, + type ApkDuplicateNameSample, + type ApkDuplicateNames, + type ApkV1Signature, +} from "../domain/apkSignature/apkV1Signature.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { zipPackageFormatForPath } from "../domain/zipPackageFormat.js"; +import { + apkOutputBudgetError, + apkSignatureFailure, + mapApkBlockError, + mapApkSchemeError, + mapCentralDirectoryError, + v1SignerCapacityError, +} from "./apkSignatureFailures.js"; +import { + APK_SIGNATURE_LIMITS, + APK_SIGNATURE_PROVIDER_IDENTITY, +} from "./ApkSignatureRelease.js"; +import { + certificateBytes, + certificateFacts, + jsonStringBytes, +} from "./apkCertificateFacts.js"; +import { + decodeV1Signer, + projectedV1SignerBytes, + v1PostureFingerprint, + v1SignerFacts, + v1SignerNotes, + type DecodedV1Signer, +} from "./apkV1Signers.js"; + +const OPERATION = "inspect_apk_signature"; + +type Signer = ApkSignatureResult["schemes"]["v2"]["signers"][number]; +type SignerV3 = ApkSignatureResult["schemes"]["v3"]["signers"][number]; + +/** Compares a signed digest with the recomputed content digest; null when not computable. */ +interface DigestMatcher { + readonly match: (algorithmId: number, signed: Uint8Array) => boolean | null; + /** True once a SHA-256/512 digest was requested but the layout prevented recomputing it. */ + readonly layoutPreventedRecompute: () => boolean; +} + +const nodeHash: HashFactory = (algorithm) => { + const hash = createHash(algorithm); + return { + update: (data) => { + hash.update(data); + }, + digest: () => new Uint8Array(hash.digest()), + }; +}; + +/** Recompute each content digest at most once per algorithm, lazily. */ +const digestMatcher = ( + bytes: Uint8Array, + tail: ZipTail, + blockStart: number | null, +): DigestMatcher => { + const cache = new Map<"sha256" | "sha512", Uint8Array | null>(); + return { + match: (algorithmId, signed) => { + const algorithm = contentDigestAlgorithm(algorithmId); + // verity (fs-verity Merkle) digests and unknown algorithms are not recomputed. + if ( + blockStart === null || + (algorithm !== "sha256" && algorithm !== "sha512") + ) + return null; + if (!cache.has(algorithm)) + cache.set( + algorithm, + computeApkContentDigest( + bytes, + { tail, blockStart, algorithm }, + nodeHash, + ), + ); + const computed = cache.get(algorithm) ?? null; + return computed === null ? null : Buffer.from(computed).equals(signed); + }, + layoutPreventedRecompute: () => + [...cache.values()].some((value) => value === null), + }; +}; + +/** Proof-of-rotation capability bits (apksig SigningCertificateLineage). */ +const capabilities = ( + flags: number, +): SignerV3["rotation"]["lineage"][number]["capabilities"] => ({ + installed_data: (flags & 0x1) !== 0, + shared_uid: (flags & 0x2) !== 0, + permission: (flags & 0x4) !== 0, + rollback: (flags & 0x8) !== 0, + auth: (flags & 0x10) !== 0, +}); + +/** A decoded signer plus its decoded (or failed) declared lineage. */ +interface DecodedSigner { + readonly signer: ApkSigner; + readonly rotation: + | { readonly present: false } + | { readonly present: true; readonly nodes: readonly ApkLineageNode[] } + | { readonly present: true; readonly decodeError: string }; +} + +interface DecodedScheme { + readonly uninterpretedBytes: number; + readonly present: boolean; + readonly signers: readonly DecodedSigner[]; +} + +const lineageOf = ( + signer: ApkSigner, +): Result => { + const attribute = signer.attributes.find( + (a) => a.id === PROOF_OF_ROTATION_ATTR_ID, + ); + if (attribute === undefined) return ok({ present: false }); + const nodes = decodeRotationLineage(attribute.value); + if (nodes.ok) return ok({ present: true, nodes: nodes.value }); + // A malformed lineage is reported, not dropped; an over-cap lineage is refused. + return nodes.error.kind === "malformed-signer" + ? ok({ present: true, decodeError: nodes.error.detail }) + : nodes; +}; + +/** + * Upper bound on the result size from decoded counts, before materialising + * any of it. The stamp certificate is no larger than its block value. + */ +const projectedOutputBytes = ( + schemes: readonly DecodedScheme[], + v1: ApkV1Signature, + v1Decoded: readonly DecodedV1Signer[], + stamp: Uint8Array | undefined, +): number => { + let total = 4096 + certificateBytes(stamp); + for (const name of [...v1.signatureFiles, ...v1.blockFiles]) + total += 1 + jsonStringBytes(name); + for (const signer of v1Decoded) total += projectedV1SignerBytes(signer); + for (const scheme of schemes) + for (const { signer, rotation } of scheme.signers) { + total += 512 + certificateBytes(signer.certificates[0]); + for (const digest of signer.digests) + total += 160 + 2 * digest.digest.length; + total += 160 * signer.signatures.length; + if (rotation.present && "nodes" in rotation) + for (const node of rotation.nodes) + total += 192 + certificateBytes(node.certificate); + } + return total; +}; + +const digestsOf = ( + signer: ApkSigner, + matcher: DigestMatcher, +): Signer["digests"] => + signer.digests.map((d) => ({ + signature_algorithm_id: d.algorithmId, + uninterpreted_bytes: d.uninterpretedBytes, + algorithm: contentDigestAlgorithm(d.algorithmId) ?? "unknown", + declared_digest_hex: Buffer.from( + d.digest.buffer, + d.digest.byteOffset, + d.digest.byteLength, + ).toString("hex"), + matches_apk: matcher.match(d.algorithmId, d.digest), + })); + +const baseSigner = (signer: ApkSigner, matcher: DigestMatcher): Signer => ({ + certificate: signer.certificates[0] + ? certificateFacts(signer.certificates[0]) + : null, + digests: digestsOf(signer, matcher), + uninterpreted_signed_data_bytes: signer.uninterpretedSignedDataBytes, + uninterpreted_signer_bytes: signer.uninterpretedSignerBytes, + signatures: signer.signatures.map( + ({ algorithmId, signature, uninterpretedBytes }) => ({ + signature_algorithm_id: algorithmId, + algorithm: contentDigestAlgorithm(algorithmId) ?? "unknown", + signature_bytes: signature.length, + uninterpreted_bytes: uninterpretedBytes, + }), + ), + public_key_bytes: signer.publicKey.length, + min_sdk: signer.minSdk, + max_sdk: signer.maxSdk, +}); + +const rotationFacts = ( + rotation: DecodedSigner["rotation"], +): SignerV3["rotation"] => { + if (!rotation.present) + return { present: false, lineage: [], decode_error: null }; + if ("decodeError" in rotation) + return { present: true, lineage: [], decode_error: rotation.decodeError }; + return { + present: true, + lineage: rotation.nodes.map((node) => ({ + certificate: certificateFacts(node.certificate), + flags: node.flags, + capabilities: capabilities(node.flags), + })), + decode_error: null, + }; +}; + +const v3Signer = ( + decoded: DecodedSigner, + matcher: DigestMatcher, +): SignerV3 => ({ + ...baseSigner(decoded.signer, matcher), + rotation: rotationFacts(decoded.rotation), +}); + +/** All recomputed digests agree → true; any mismatch → false; none computed → null. */ +const selfConsistency = ( + signers: readonly { readonly digests: Signer["digests"] }[], +): boolean | null => { + const results = signers + .flatMap((s) => s.digests.map((d) => d.matches_apk)) + .filter((m): m is boolean => m !== null); + if (results.length === 0) return null; + return results.every(Boolean); +}; + +/** A reported block-scheme signer as the posture derivation reads it. */ +const postureSigner = (signer: Signer | SignerV3): ApkPostureSigner => { + const fingerprint = signer.certificate?.sha256_fingerprint ?? null; + if (!("rotation" in signer) || !signer.rotation.present) + return { fingerprint, lineage: { kind: "absent" } }; + if (signer.rotation.decode_error !== null) + return { fingerprint, lineage: { kind: "undecoded" } }; + return { + fingerprint, + lineage: { + kind: "decoded", + fingerprints: signer.rotation.lineage.map( + (node) => node.certificate?.sha256_fingerprint ?? null, + ), + }, + }; +}; + +/** Longest name prefix a limitation quotes; the stored length is stated past it. */ +const NAME_DISPLAY_BYTES = 256; + +/** + * A duplicated name as a limitation shows it: JSON-quoted, and cut at a + * UTF-8 boundary within NAME_DISPLAY_BYTES with its stored length. + */ +const displayName = ({ name, storedBytes }: ApkDuplicateName): string => { + const encoded = Buffer.from(name, "utf8"); + if (encoded.length <= NAME_DISPLAY_BYTES) return JSON.stringify(name); + let cut = NAME_DISPLAY_BYTES; + while (cut > 0 && ((encoded[cut] ?? 0) & 0xc0) === 0x80) cut -= 1; + const prefix = encoded.subarray(0, cut).toString("utf8"); + return `${JSON.stringify(prefix)}… (${String(storedBytes)}-byte name)`; +}; + +const listed = ({ count, sample }: ApkDuplicateNameSample): string => { + const more = count - sample.length; + return `${sample.map(displayName).join(", ")}${more > 0 ? ` and ${String(more)} more` : ""}`; +}; + +const plural = (count: number): string => + `${String(count)} ${count === 1 ? "name" : "names"}`; + +/** Why apksig would reject the v1 signature of an archive with duplicate names. */ +const duplicateEntriesLimitation = ( + duplicates: ApkDuplicateNames, +): string | null => { + const { count, records, apksigRecords, unread } = duplicates; + if (count === 0 && unread.count === 0) return null; + const partial = apksigRecords < records; + const scope = partial + ? `apksig reads only the first ${String(apksigRecords)} of the ${String(records)} central-directory records (it takes the count from the classic end-of-central-directory record's 16-bit field and ignores the Zip64 count). ` + : ""; + const within = + count > 0 + ? `${partial ? "Among those, the" : "The"} central directory holds ${plural(count)} more than once (${listed(duplicates)}); apksig rejects the v1 signature of an archive with any duplicate entry ("Duplicate entry"). A v1 signer whose signature block, .SF or META-INF/MANIFEST.MF name is duplicated carries decode_error; other v1 signers are reported as declared.` + : ""; + const beyond = + unread.count > 0 + ? `${count > 0 ? " " : ""}${plural(unread.count)} (${listed(unread)}) ${unread.count === 1 ? "is" : "are"} duplicated only by records past those, which apksig does not read.` + : ""; + return `${scope}${within}${beyond}`; +}; + +const LAYOUT_LIMITATION = + "The content digest was not recomputed because the central directory is not immediately followed by the end-of-central-directory record (declared size mismatch or an intervening Zip64 record); matches_apk is null for those digests."; + +/** Read exactly the selected APK bytes and report its signature schemes. */ +export class ApkSignatureProvider implements ApkSignaturePort { + /** Owns the selected file's descriptor, retaining it when closing fails. */ + readonly #resources = new ArtifactResourceScope(); + + async close(): Promise { + try { + await this.#resources.close(); + } catch (cause: unknown) { + if ( + !(cause instanceof ArtifactReaderFailure) || + cause.cleanup === undefined + ) + throw cause; + const error = new ProviderCleanupError( + APK_SIGNATURE_PROVIDER_IDENTITY.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { cause, operation: OPERATION }, + ); + bindArtifactFileCleanup(cause, error); + throw error; + } + } + + async inspect( + input: { readonly path: string }, + options?: ExecutionOptions, + ): Promise> { + try { + return await this.#resources.run(async () => { + const release = await apkSignatureAdmission.acquire(options?.signal); + try { + const selected = await readStableArtifact( + input.path, + APK_SIGNATURE_LIMITS.inputBytes, + options?.signal, + undefined, + this.#resources, + ); + assertArtifactReadActive(options?.signal); + const analyzed = this.#analyze(selected.bytes, { + path: input.path, + sha256: selected.sha256, + bytes: selected.bytes.length, + }); + assertArtifactReadActive(options?.signal); + return analyzed; + } finally { + release(); + } + }); + } catch (cause: unknown) { + return err(apkSignatureFailure(cause, input.path, options?.signal)); + } + } + + #analyze( + bytes: Uint8Array, + artifact: { path: string; sha256: string; bytes: number }, + ): Result { + const tail = parseZipTail(bytes); + if (!tail.ok) return err(mapApkBlockError(tail.error, artifact.path)); + const block = locateApkSigningBlock(bytes, tail.value); + if (!block.ok) return err(mapApkBlockError(block.error, artifact.path)); + + const v1Read = readV1(bytes, tail.value, block.value); + if (!v1Read.ok) return v1Read; + const { v1, decoded: v1Decoded, duplicates } = v1Read.value; + + const v2 = decodeScheme(block.value, APK_BLOCK_ID.signatureV2, "v2"); + if (!v2.ok) return err(v2.error); + const v3 = decodeScheme(block.value, APK_BLOCK_ID.signatureV3, "v3"); + if (!v3.ok) return err(v3.error); + const v31 = decodeScheme(block.value, APK_BLOCK_ID.signatureV31, "v3.1"); + if (!v31.ok) return err(v31.error); + + // Account for representation expansion before allocating the report. + const projected = projectedOutputBytes( + [v2.value, v3.value, v31.value], + v1, + v1Decoded, + sourceStampPair(block.value)?.value, + ); + if (projected > APK_SIGNATURE_LIMITS.outputBytes) + return err(apkOutputBudgetError(projected)); + + const matcher = digestMatcher( + bytes, + tail.value, + block.value?.blockStart ?? null, + ); + const v1signers = v1Decoded.map(v1SignerFacts); + const v2signers = v2.value.signers.map((s) => + baseSigner(s.signer, matcher), + ); + const v3signers = v3.value.signers.map((s) => v3Signer(s, matcher)); + const v31signers = v31.value.signers.map((s) => v3Signer(s, matcher)); + + const sourceStamp = sourceStampFacts(block.value); + const derived = deriveApkPosture({ + v1Present: v1.present, + v2Present: v2.value.present, + v3Present: v3.value.present, + v31Present: v31.value.present, + sourceStampPresent: sourceStamp.present, + blockSchemes: [ + { scheme: "v2", signers: v2signers.map(postureSigner) }, + { scheme: "v3", signers: v3signers.map(postureSigner) }, + { scheme: "v3.1", signers: v31signers.map(postureSigner) }, + ], + v1SignerFingerprints: v1signers.map(v1PostureFingerprint), + selfConsistent: selfConsistency([ + ...v2signers, + ...v3signers, + ...v31signers, + ]), + }); + + const result: ApkSignatureResult = { + artifact, + schemes: { + v1: { + present: v1.present, + has_manifest: v1.hasManifest, + signature_files: [...v1.signatureFiles], + block_files: [...v1.blockFiles], + signers: v1signers, + }, + v2: { + present: v2.value.present, + signers: v2signers, + uninterpreted_bytes: v2.value.uninterpretedBytes, + }, + v3: { + present: v3.value.present, + signers: v3signers, + uninterpreted_bytes: v3.value.uninterpretedBytes, + }, + "v3.1": { + present: v31.value.present, + signers: v31signers, + uninterpreted_bytes: v31.value.uninterpretedBytes, + }, + }, + source_stamp: sourceStamp, + posture: derived.posture, + runtime_execution: "not-performed", + }; + + const uninterpreted = apkSignatureUninterpretedBytes(result); + const limitations = [ + ...(uninterpreted === 0 + ? [] + : [ + `${String(uninterpreted)} bytes outside known signing fields were not interpreted; suffixes do not by themselves establish malformed signing.`, + ]), + ...APK_SIGNATURE_LIMITATIONS, + ...(matcher.layoutPreventedRecompute() ? [LAYOUT_LIMITATION] : []), + ...[duplicateEntriesLimitation(duplicates)].filter( + (note): note is string => note !== null, + ), + ...v1SignerNotes(v1Decoded), + ...derived.notes, + ]; + // The budget covers everything emitted: limitations repeat archive names. + const serialized = Buffer.byteLength( + JSON.stringify({ result, limitations }), + ); + if (serialized > APK_SIGNATURE_LIMITS.outputBytes) + return err(apkOutputBudgetError(serialized)); + + const format = + (zipPackageFormatForPath(artifact.path) ?? "zip") === "apk" + ? "apk" + : "zip"; + return ok( + createAnalysisExecution(result, APK_SIGNATURE_PROVIDER_IDENTITY, { + subject: { path: artifact.path, format, sha256: artifact.sha256 }, + locations: [{ kind: "artifact-path", path: artifact.path }], + limitations, + }) as ApkSignatureExecution, + ); + } +} + +/** + * v1 signing from the central directory, with each paired block decoded. A + * directory that cannot be walked leaves v1 unknown and is refused. + */ +const readV1 = ( + bytes: Uint8Array, + tail: ZipTail, + block: ApkSigningBlock | null, +): Result< + { + readonly v1: ApkV1Signature; + readonly decoded: DecodedV1Signer[]; + readonly duplicates: ApkDuplicateNames; + }, + AnalysisError +> => { + const entries = centralDirectoryV1SignatureEntries( + bytes, + tail.centralDirectoryOffset, + tail.centralDirectorySize, + tail.totalEntries, + tail.classicEntryCount, + ); + if (!entries.ok) return err(mapCentralDirectoryError(entries.error)); + const v1 = detectV1Signature(entries.value.entries); + if (v1.signers.length > MAX_V1_SIGNERS) + return err(v1SignerCapacityError(MAX_V1_SIGNERS)); + // Entry data lies before the signing block, or the directory without one. + const entriesEnd = block?.blockStart ?? tail.centralDirectoryOffset; + return ok({ + v1, + duplicates: entries.value.duplicates, + decoded: v1.signers.map((signer) => + decodeV1Signer(bytes, signer, entriesEnd), + ), + }); +}; + +/** The declared stamp certificate, preferring the V2 stamp block as apksig does. */ +const sourceStampPair = (block: ApkSigningBlock | null) => + block?.pairs.find((p) => p.id === APK_BLOCK_ID.sourceStampV2) ?? + block?.pairs.find((p) => p.id === APK_BLOCK_ID.sourceStampV1); + +const sourceStampFacts = ( + block: ApkSigningBlock | null, +): ApkSignatureResult["source_stamp"] => { + const pair = sourceStampPair(block); + if (pair === undefined) + return { present: false, certificate: null, decode_error: null }; + const certificate = decodeSourceStampCertificate(pair.value); + return certificate.ok + ? { + present: true, + certificate: certificateFacts(certificate.value), + decode_error: null, + } + : { + present: true, + certificate: null, + decode_error: + certificate.error.kind === "malformed-signer" + ? certificate.error.detail + : `more ${certificate.error.resource} than ${String(certificate.error.limit)}`, + }; +}; + +/** Decode one scheme's signers and their declared lineages, mapping failures by scheme. */ +const decodeScheme = ( + block: ApkSigningBlock | null, + id: number, + version: ApkSchemeVersion, +): Result => { + const pair = block?.pairs.find((p) => p.id === id); + if (pair === undefined) + return ok({ present: false, signers: [], uninterpretedBytes: 0 }); + const signers = decodeSigners(pair.value, version); + if (!signers.ok) return err(mapApkSchemeError(signers.error, version)); + const decoded: DecodedSigner[] = []; + for (const signer of signers.value.signers) { + const rotation = + version === "v2" ? ok({ present: false } as const) : lineageOf(signer); + if (!rotation.ok) return err(mapApkSchemeError(rotation.error, version)); + decoded.push({ signer, rotation: rotation.value }); + } + return ok({ + present: true, + signers: decoded, + uninterpretedBytes: signers.value.uninterpretedBytes, + }); +}; diff --git a/src/apkSignature/ApkSignatureRelease.ts b/src/apkSignature/ApkSignatureRelease.ts new file mode 100644 index 0000000000..110cc9c054 --- /dev/null +++ b/src/apkSignature/ApkSignatureRelease.ts @@ -0,0 +1,23 @@ +/** Identity of REA's in-process APK signature reader (no external engine, no device). */ +export const APK_SIGNATURE_PROVIDER_IDENTITY = { + id: "apk-signature", + name: "REA APK signature reader", + version: null, +} as const; + +export const APK_SIGNATURE_LIMITS = { + /** Pending requests retain no APK bytes; process-wide scans are serialized. */ + waitingScans: 8, + /** + * Whole-APK bounded read; the content digest needs the whole file. Above + * Play's 200 MB APK ceiling with headroom for sideloaded/system APKs. + */ + inputBytes: 512 * 1024 * 1024, + /** + * One v1 signature block (PKCS#7) after inflation. Real blocks hold a + * certificate or a short chain (a few KiB); this bounds each inflation. + */ + v1BlockBytes: 1024 * 1024, + /** Inline result ceiling, projected before materialising and re-checked exactly; refused, not truncated. */ + outputBytes: 8 * 1024 * 1024, +} as const; diff --git a/src/apkSignature/apkCertificateFacts.ts b/src/apkSignature/apkCertificateFacts.ts new file mode 100644 index 0000000000..7c5886ac8c --- /dev/null +++ b/src/apkSignature/apkCertificateFacts.ts @@ -0,0 +1,65 @@ +/** + * The one owner of certificate facts for every APK signing scheme (v1, v2, + * v3/v3.1, lineage nodes and source stamps): identity is SHA-256 over the raw + * embedded DER, and the readable X.509 facts come from the platform parser. + */ +import { createHash, X509Certificate } from "node:crypto"; + +import type { ApkSignatureResult } from "../domain/apkSignature/apkSignatureReport.js"; + +export type Certificate = + ApkSignatureResult["schemes"]["v2"]["signers"][number]["certificate"]; + +/** SHA-256 over the raw embedded DER — exactly what apksig/apksigner hash. */ +const derFingerprint = (der: Uint8Array): string => + createHash("sha256").update(der).digest("hex"); + +/** Each fact is read independently so one unsupported field does not hide the rest. */ +const attempt = (read: () => T): T | null => { + try { + return read(); + } catch { + return null; + } +}; + +/** Identity is the raw-DER fingerprint; parsed X.509 facts are null when unreadable. */ +export const certificateFacts = (der: Uint8Array): Certificate => { + const cert = attempt(() => new X509Certificate(Buffer.from(der))); + return { + sha256_fingerprint: derFingerprint(der), + subject: cert?.subject ?? null, + issuer: cert?.issuer ?? null, + serial_number: cert?.serialNumber ?? null, + not_before: cert ? attempt(() => cert.validFromDate.toISOString()) : null, + not_after: cert ? attempt(() => cert.validToDate.toISOString()) : null, + public_key_algorithm: cert + ? attempt(() => cert.publicKey.asymmetricKeyType ?? null) + : null, + }; +}; + +/** + * Serialized bytes per DER byte of a distinguished name. RFC 2253 escapes a + * byte as `\XX` or `\"`, which JSON-escapes to 4 bytes (measured with the + * platform parser); the serial's hex is 2 bytes per DER byte, and the + * subject, issuer and serial are disjoint parts of the DER. + */ +const DN_EXPANSION = 4; + +/** + * Upper bound on one certificate's serialized facts, for admission before + * materialising; the provider's exact serialized re-check stays authoritative. + */ +export const certificateBytes = (der: Uint8Array | undefined): number => + 256 + DN_EXPANSION * (der?.length ?? 0); + +/** Upper bound on a certificate summary (fingerprint and subject). */ +export const certificateSummaryBytes = (der: Uint8Array): number => + 128 + DN_EXPANSION * der.length; + +/** + * Upper bound on a serialized string: a UTF-16 unit is at most 3 UTF-8 bytes, + * and a control character JSON-escapes to `\u00XX` (6 bytes). + */ +export const jsonStringBytes = (text: string): number => 2 + 6 * text.length; diff --git a/src/apkSignature/apkSignatureFailures.ts b/src/apkSignature/apkSignatureFailures.ts new file mode 100644 index 0000000000..1867972e16 --- /dev/null +++ b/src/apkSignature/apkSignatureFailures.ts @@ -0,0 +1,219 @@ +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + ArtifactFileCleanupFailure, + bindArtifactFileCleanup, +} from "../artifacts/ArtifactFileOperation.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../domain/analysisErrorCore.js"; +import type { ApkSchemeError } from "../domain/apkSignature/apkSignatureSchemes.js"; +import type { ApkSigningBlockError } from "../domain/apkSignature/apkSigningBlock.js"; +import type { ApkCentralDirectoryError } from "../domain/apkSignature/apkV1Signature.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { + APK_SIGNATURE_LIMITS, + APK_SIGNATURE_PROVIDER_IDENTITY, +} from "./ApkSignatureRelease.js"; + +const OPERATION = "inspect_apk_signature"; + +const inputError = (message: string): AnalysisInputError => + new AnalysisInputError(OPERATION, undefined, [ + { path: ["path"], reason: "invalid_value", message }, + ]); + +/** Map a tagged signing-block framing failure to the shared analysis error algebra. */ +export const mapApkBlockError = ( + error: ApkSigningBlockError, + path: string, +): AnalysisError => { + switch (error.kind) { + case "not-a-zip": + return new AnalysisUnsupportedTargetError( + OPERATION, + path, + `The selected file is not a ZIP/APK (${error.detail}).`, + { remediationAction: "Select a local .apk/.zip/.aab archive." }, + ); + case "zip64-unsupported": + return new AnalysisUnsupportedTargetError( + OPERATION, + path, + `This archive uses a Zip64 structure this reader does not decode (${error.detail}).`, + ); + case "malformed-zip": + return inputError( + `Damaged ZIP/APK at byte ${String(error.offset)}: ${error.detail}.`, + ); + case "malformed-block": + return inputError( + `Malformed APK Signing Block at byte ${String(error.offset)}: ${error.detail}.`, + ); + case "budget-exceeded": + return new AnalysisResourceConstraintError( + OPERATION, + "capacity", + `APK Signing Block holds more ${error.resource} than the ${String(error.limit)} limit.`, + { max_signing_block_pairs: error.limit }, + ); + } +}; + +/** Map a tagged signer/lineage decoding failure, naming the scheme it came from. */ +export const mapApkSchemeError = ( + error: ApkSchemeError, + scheme: string, +): AnalysisError => + error.kind === "malformed-signer" + ? inputError( + `Malformed APK Signature Scheme ${scheme} block: ${error.detail}.`, + ) + : new AnalysisResourceConstraintError( + OPERATION, + "capacity", + `APK Signature Scheme ${scheme} holds more ${error.resource} than the ${String(error.limit)} limit.`, + { [`max_${error.resource.replace(/-/g, "_")}`]: error.limit }, + ); + +/** A central directory that cannot be walked leaves v1 presence unknown: refuse. */ +export const mapCentralDirectoryError = ( + error: ApkCentralDirectoryError, +): AnalysisError => + error.kind === "budget-exceeded" + ? new AnalysisResourceConstraintError( + OPERATION, + "capacity", + `The central directory holds more than ${String(error.limit)} META-INF v1 signing entries.`, + { max_v1_signature_entries: error.limit }, + ) + : inputError( + `Malformed ZIP central directory at byte ${String(error.offset)}: ${error.detail}.`, + ); + +/** More v1 signers than a signed APK carries: refused rather than decoded. */ +export const v1SignerCapacityError = (limit: number): AnalysisError => + new AnalysisResourceConstraintError( + OPERATION, + "capacity", + `The archive pairs more than ${String(limit)} v1 signature blocks with .SF files.`, + { max_v1_signers: limit }, + ); + +/** The decoded result would exceed the inline output budget. */ +export const apkOutputBudgetError = (projectedBytes: number): AnalysisError => + new AnalysisResourceConstraintError( + OPERATION, + "capacity", + `The decoded signing report (${String(projectedBytes)} bytes) exceeds the ${String(APK_SIGNATURE_LIMITS.outputBytes)}-byte output budget.`, + { max_output_bytes: APK_SIGNATURE_LIMITS.outputBytes }, + ); + +/** + * Map a read or analysis failure, keeping a failed descriptor close as a + * cleanup fact bound to the error (its lease retried by the provider's + * close()), as the other stable-read providers do. + */ +export const apkSignatureFailure = ( + cause: unknown, + path: string, + signal?: AbortSignal, +): AnalysisError => { + const primary = + cause instanceof ArtifactFileCleanupFailure && + cause.fileCleanupFailure.outcome.kind === "failed" + ? primaryFailure(cause.fileCleanupFailure.outcome.cause, path) + : cause instanceof ArtifactFileCleanupFailure && signal?.aborted + ? new AnalysisCancelledError(OPERATION, { cause: signal.reason }) + : primaryFailure(cause, path); + if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) + return primary; + const cleanup = new ProviderCleanupError( + APK_SIGNATURE_PROVIDER_IDENTITY.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { cause, operation: OPERATION }, + ); + const error = analysisErrorWithCleanupFailure(primary, cleanup, OPERATION); + bindArtifactFileCleanup(cause, error); + return error; +}; + +/** Distinguish cancellation, access, integrity and limits for the primary failure. */ +const primaryFailure = (cause: unknown, path: string): AnalysisError => { + if (cause instanceof AnalysisError) return cause; + if (cause instanceof ArtifactReaderFailure) + return artifactReaderFailure(cause, path); + if (cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(OPERATION, path, cause.code, { + cause, + }); + if ( + ["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG"].includes( + String(cause.code), + ) + ) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `No regular file can be read at the selected path (${String(cause.code)}): ${path}.`, + }, + ]); + } + return adapterFailure(cause, path); +}; + +/** + * The reader owns the meaning of its tagged failures: only a path that is not + * a regular file describes the caller's selection. I/O, an unavailable or + * closed resource scope, and a non-byte stream are execution failures. + */ +const artifactReaderFailure = ( + cause: ArtifactReaderFailure, + path: string, +): AnalysisError => { + switch (cause.reason) { + case "integrity": + return new AnalysisArtifactChangedError(OPERATION, path, cause.message, { + cause, + }); + case "cancelled": + return new AnalysisCancelledError(OPERATION, { cause: cause.cause }); + case "limit": + return new AnalysisResourceConstraintError( + OPERATION, + "file-size", + cause.message, + { max_input_bytes: APK_SIGNATURE_LIMITS.inputBytes }, + { + cause, + remediationAction: + "The size budget is fixed; select a smaller archive.", + }, + ); + case "path": + return new AnalysisInputError(OPERATION, { cause }, [ + { path: ["path"], reason: "invalid_format", message: cause.message }, + ]); + default: + return adapterFailure(cause, path); + } +}; + +const adapterFailure = (cause: unknown, path: string): AnalysisError => + new ProviderAdapterError(APK_SIGNATURE_PROVIDER_IDENTITY.id, OPERATION, { + cause, + diagnostics: { + path, + reason: cause instanceof Error ? cause.message : String(cause), + }, + }); diff --git a/src/apkSignature/apkV1Signers.test.ts b/src/apkSignature/apkV1Signers.test.ts new file mode 100644 index 0000000000..ee3ca6982c --- /dev/null +++ b/src/apkSignature/apkV1Signers.test.ts @@ -0,0 +1,88 @@ +import { X509Certificate } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { + LEAF_CERTIFICATE, + OPENSSL_BLOCK_KEY_ID, + organizationalUnits, + signedDataBlock, + signerInfo, + withSubject, +} from "../../tests/fixtures/apkSignature/v1SignatureBlock.js"; +import { decodeV1SignatureBlock } from "../domain/apkSignature/apkV1SignatureBlock.js"; +import { + projectedV1SignerBytes, + v1PostureFingerprint, + v1SignerFacts, + v1SignerNotes, + type DecodedV1Signer, +} from "./apkV1Signers.js"; + +const signerOf = ( + certificate: Uint8Array, + block: Uint8Array = signedDataBlock({ + certificates: [certificate], + signerInfos: [signerInfo({ names: certificate })], + }), +): DecodedV1Signer => { + const decoded = decodeV1SignatureBlock(block); + if (!decoded.ok) throw new Error(JSON.stringify(decoded.error)); + const name = "META-INF/CERT.RSA"; + return { + entry: { + signatureFile: "META-INF/CERT.SF", + duplicate: null, + block: { + name, + rawName: new TextEncoder().encode(name), + flags: 0, + method: 0, + crc32: 0, + compressedSize: 0, + uncompressedSize: 0, + localHeaderOffset: 0, + }, + }, + declaresEncryption: false, + decoded, + }; +}; + +describe("v1 signer output projection", () => { + it("is an upper bound when the named certificate's subject is all escapes", () => { + // RFC 2253 escapes `"` as `\"`, 4 bytes once JSON-escaped, and the named + // certificate's subject is serialized twice. + const certificate = withSubject( + LEAF_CERTIFICATE, + organizationalUnits(64, '"'.repeat(64)), + ); + expect(new X509Certificate(certificate).subject).toContain('\\"'); + const signer = signerOf(certificate); + const facts = v1SignerFacts(signer); + expect(facts.certificate?.subject).not.toBeNull(); + expect(projectedV1SignerBytes(signer)).toBeGreaterThanOrEqual( + Buffer.byteLength(JSON.stringify(facts)), + ); + }); +}); + +describe("v1 signers identified by subjectKeyIdentifier", () => { + it("keeps the declared certificate but leaves the posture comparison unknown", () => { + const keyIdentified = signerOf(LEAF_CERTIFICATE, OPENSSL_BLOCK_KEY_ID); + const facts = v1SignerFacts(keyIdentified); + expect(facts.signer_infos[0]?.identified_by).toBe("subject-key-identifier"); + expect(facts.certificate?.sha256_fingerprint).toMatch(/^[a-f0-9]{64}$/); + expect(v1PostureFingerprint(facts)).toBeNull(); + expect(v1SignerNotes([keyIdentified])).toEqual([ + expect.stringContaining( + "META-INF/CERT.RSA: a SignerInfo names its certificate by subjectKeyIdentifier", + ) as string, + ]); + const bySerial = v1SignerFacts(signerOf(LEAF_CERTIFICATE)); + expect(v1PostureFingerprint(bySerial)).toBe( + bySerial.certificate?.sha256_fingerprint, + ); + expect(v1SignerNotes([signerOf(LEAF_CERTIFICATE)])).toEqual([]); + }); +}); diff --git a/src/apkSignature/apkV1Signers.ts b/src/apkSignature/apkV1Signers.ts new file mode 100644 index 0000000000..7620f1b7fd --- /dev/null +++ b/src/apkSignature/apkV1Signers.ts @@ -0,0 +1,208 @@ +/** + * Read each v1 (JAR) signer's PKCS#7 block from the archive and materialise + * its declared facts. The domain owns the ZIP and DER structure; this adapter + * owns inflation (bounded by the inflater itself, not the declared size) and + * hands every certificate to the shared certificate owner. + */ +import { inflateRawSync } from "node:zlib"; + +import type { ApkSignatureResult } from "../domain/apkSignature/apkSignatureReport.js"; +import { + centralDeclaresEncryption, + localEntryData, + type ApkV1SignerEntry, +} from "../domain/apkSignature/apkV1Signature.js"; +import { + decodeV1SignatureBlock, + type ApkV1SignedData, +} from "../domain/apkSignature/apkV1SignatureBlock.js"; +import { + certificateBytes, + certificateFacts, + certificateSummaryBytes, + jsonStringBytes, + type Certificate, +} from "./apkCertificateFacts.js"; +import { APK_SIGNATURE_LIMITS } from "./ApkSignatureRelease.js"; + +type V1Signer = ApkSignatureResult["schemes"]["v1"]["signers"][number]; + +/** One v1 signer: its decoded SignedData, or why it could not be read. */ +export interface DecodedV1Signer { + readonly entry: ApkV1SignerEntry; + /** The block's local or central header sets the encryption flag (bit 0). */ + readonly declaresEncryption: boolean; + readonly decoded: + | { readonly ok: true; readonly value: ApkV1SignedData } + | { readonly ok: false; readonly error: string }; +} + +/** + * One serialized SignerInfo: two OIDs of at most 64 content bytes, each at + * most 4 dotted-text bytes per content byte, plus a fingerprint and keys. + */ +const SIGNER_INFO_BYTES = 768; + +/** Upper bound on one v1 signer's serialized size, before it is materialised. */ +export const projectedV1SignerBytes = ({ + entry, + decoded, +}: DecodedV1Signer): number => { + const total = + 512 + + jsonStringBytes(entry.signatureFile) + + jsonStringBytes(entry.block.name); + if (!decoded.ok) return total + jsonStringBytes(decoded.error); + const { certificates, signerInfos } = decoded.value; + // Every certificate is summarised in `certificates`; at most one, the + // named signer, also appears in full as `certificate`. + let largest = 0; + let summaries = 0; + for (const der of certificates) { + summaries += certificateSummaryBytes(der); + largest = Math.max(largest, certificateBytes(der)); + } + return total + summaries + largest + SIGNER_INFO_BYTES * signerInfos.length; +}; + +/** + * Read one signature block's bytes (stored or deflated) within the per-block + * bound and decode its SignedData. Every failure is the signer's own + * `decode_error`, so one damaged block neither hides the others nor reads as + * absent. + */ +export const decodeV1Signer = ( + bytes: Uint8Array, + entry: ApkV1SignerEntry, + entriesEnd: number, +): DecodedV1Signer => { + const { block } = entry; + // The local header's flag is known only once it is located. + let declaresEncryption = centralDeclaresEncryption(block); + const fail = (error: string): DecodedV1Signer => ({ + entry, + declaresEncryption, + decoded: { ok: false, error }, + }); + // apksig rejects the archive's v1 signature outright; which copy it would + // read is not defined, so the signer stays unknown. + if (entry.duplicate !== null) + return fail(`duplicate entry: ${entry.duplicate}`); + const limit = APK_SIGNATURE_LIMITS.v1BlockBytes; + // Locating reads no data, and names a Zip64 sentinel size as such rather + // than as a size over the bound. + const stored = localEntryData(bytes, block, entriesEnd); + if (!stored.ok) return fail(stored.error.detail); + ({ declaresEncryption } = stored.value); + if (block.uncompressedSize > limit) + return fail( + `signature block declares ${String(block.uncompressedSize)} bytes, over the ${String(limit)}-byte bound`, + ); + let data: Uint8Array; + if (block.method === 0) data = stored.value.data; + else if (block.method === 8) { + try { + data = inflateRawSync(stored.value.data, { maxOutputLength: limit }); + } catch (cause: unknown) { + return fail( + cause instanceof RangeError + ? `signature block inflates past the ${String(limit)}-byte bound` + : "signature block deflate stream is damaged", + ); + } + } else + return fail(`compression method ${String(block.method)} is not supported`); + if (data.length !== block.uncompressedSize) + return fail( + `signature block holds ${String(data.length)} bytes, the central directory declares ${String(block.uncompressedSize)}`, + ); + const signedData = decodeV1SignatureBlock(data); + if (signedData.ok) return { entry, declaresEncryption, decoded: signedData }; + return fail( + signedData.error.kind === "malformed-pkcs7" + ? `malformed PKCS#7: ${signedData.error.detail}` + : `more ${signedData.error.resource} than ${String(signedData.error.limit)}`, + ); +}; + +/** Facts about v1 blocks that the report has no field for. */ +export const v1SignerNotes = (signers: readonly DecodedV1Signer[]): string[] => + signers.flatMap(({ entry, declaresEncryption, decoded }) => [ + ...(declaresEncryption + ? [ + `${entry.block.name}: the entry declares encryption (general-purpose flag bit 0); apksig ignores the flag, so its bytes were read as stored and the PKCS#7 decoding decides.`, + ] + : []), + ...(decoded.ok && + decoded.value.signerInfos.some( + (info) => info.identifiedBy === "subject-key-identifier", + ) + ? [ + `${entry.block.name}: a SignerInfo names its certificate by subjectKeyIdentifier, which apksig does not resolve (it matches issuerAndSerialNumber only), so apksig's v1 verification fails for this block. The certificate it names is reported as declared, and v1_signers_match_block_signers treats this signer as unknown.`, + ] + : []), + ...(decoded.ok && decoded.value.trailingBytes > 0 + ? [ + `${entry.block.name}: ${String(decoded.value.trailingBytes)} bytes follow the PKCS#7 ContentInfo and were ignored, as apksig ignores them.`, + ] + : []), + ]); + +/** + * The certificate the posture compares for one v1 signer: null (unknown) when + * a SignerInfo is identified by subjectKeyIdentifier, which apksig cannot + * resolve, so no match is claimed that apksig would reject. + */ +export const v1PostureFingerprint = (signer: V1Signer): string | null => + signer.signer_infos.some( + (info) => info.identified_by === "subject-key-identifier", + ) + ? null + : (signer.certificate?.sha256_fingerprint ?? null); + +/** Materialise one v1 signer's declared facts through the shared certificate owner. */ +export const v1SignerFacts = ({ + entry, + decoded, +}: DecodedV1Signer): V1Signer => { + const base = { + signature_file: entry.signatureFile, + block_file: entry.block.name, + }; + if (!decoded.ok) + return { + ...base, + certificate: null, + certificates: [], + signer_infos: [], + decode_error: decoded.error, + }; + const facts = decoded.value.certificates.map(certificateFacts); + const infos = decoded.value.signerInfos; + const named = (index: number | null): Certificate => + index === null ? null : (facts[index] ?? null); + return { + ...base, + // Without verification, a block with several SignerInfos has no one signer. + certificate: + infos.length === 1 ? named(infos[0]?.certificateIndex ?? null) : null, + certificates: facts.flatMap((fact) => + fact === null + ? [] + : [ + { + sha256_fingerprint: fact.sha256_fingerprint, + subject: fact.subject, + }, + ], + ), + signer_infos: infos.map((info) => ({ + identified_by: info.identifiedBy, + certificate_sha256: + named(info.certificateIndex)?.sha256_fingerprint ?? null, + digest_algorithm_oid: info.digestAlgorithmOid, + signature_algorithm_oid: info.signatureAlgorithmOid, + })), + decode_error: null, + }; +}; diff --git a/src/application/apkSignature/ApkSignaturePort.ts b/src/application/apkSignature/ApkSignaturePort.ts new file mode 100644 index 0000000000..df0cc8f912 --- /dev/null +++ b/src/application/apkSignature/ApkSignaturePort.ts @@ -0,0 +1,23 @@ +import type { + AnalysisExecution, + ExecutionOptions, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { EvidenceSubjectTarget } from "../../domain/evidence.js"; +import type { Result } from "../../domain/result.js"; + +/** Successful inspection identifies the selected local APK/ZIP. */ +export type ApkSignatureExecution = Omit & { + readonly subject: EvidenceSubjectTarget & { + readonly format: "file" | "apk" | "zip"; + }; +}; + +/** Replaceable offline APK signature reader, independent of device/adb and network. */ +export interface ApkSignaturePort { + close(): Promise; + inspect( + input: { readonly path: string }, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/apkSignature/ApkSignatureService.test.ts b/src/application/apkSignature/ApkSignatureService.test.ts new file mode 100644 index 0000000000..2ca6181cc9 --- /dev/null +++ b/src/application/apkSignature/ApkSignatureService.test.ts @@ -0,0 +1,78 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect, it } from "vitest"; + +import { ApkSignatureProvider } from "../../apkSignature/ApkSignatureProvider.js"; +import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; +import { ok } from "../../domain/result.js"; +import { ApkSignatureService } from "./ApkSignatureService.js"; + +it("preserves pre-aborted cancellation reasons without starting the provider", async () => { + const reason = new Error("cancelled before inspection"); + const service = new ApkSignatureService({ + close: () => Promise.resolve(), + inspect: () => { + throw new Error("provider must not start"); + }, + }); + const result = await service.inspect( + { path: join(tmpdir(), "unopened.apk") }, + { signal: AbortSignal.abort(reason) }, + ); + if (result.ok) throw new Error("expected cancellation"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); +}); + +it("retains completed APK Evidence and the abort reason on late cancellation", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-apk-service-")); + const provider = new ApkSignatureProvider(); + try { + const path = join(root, "unsigned.apk"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("classes.dex", new TextReader("x")); + await writeFile(path, await writer.close()); + const completed = await provider.inspect({ path }); + if (!completed.ok) throw completed.error; + + const controller = new AbortController(); + const reason = new Error("late abort reason"); + const result = await new ApkSignatureService({ + close: () => Promise.resolve(), + inspect: () => { + controller.abort(reason); + return Promise.resolve(ok(completed.value)); + }, + }).inspect({ path, detail: "complete" }, { signal: controller.signal }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toMatchObject({ + operation: "inspect_apk_signature", + normalized_result: completed.value.result, + }); + } finally { + await provider.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +it("refuses summary acquisition before reading when complete Evidence cannot be retained", async () => { + const service = new ApkSignatureService({ + close: () => Promise.resolve(), + inspect: () => { + throw new Error("inspection must not start without retention"); + }, + }); + const result = await service.inspect({ + path: join(tmpdir(), "not-opened.apk"), + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); +}); diff --git a/src/application/apkSignature/ApkSignatureService.ts b/src/application/apkSignature/ApkSignatureService.ts new file mode 100644 index 0000000000..bc6bbefac4 --- /dev/null +++ b/src/application/apkSignature/ApkSignatureService.ts @@ -0,0 +1,111 @@ +import { inspectApkSignatureInputSchema } from "../../domain/apkSignature/apkSignatureQuery.js"; +import { + requireAnalysisViewRetention, + projectRetainedAnalysis, + type AnalysisViewRetention, +} from "../analysisView/AnalysisViewService.js"; +import { isAbsolute } from "node:path"; + +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { ApkSignaturePort } from "./ApkSignaturePort.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import { + apkSignatureMatchesSubject, + apkSignatureResultSchema, +} from "../../domain/apkSignature/apkSignatureReport.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { err, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; + +const OPERATION = "inspect_apk_signature"; + +/** Shared artifact validation and typed inline Evidence for CLI/MCP. */ +export class ApkSignatureService { + constructor(readonly provider: ApkSignaturePort) {} + + close(): Promise { + return this.provider.close(); + } + + /** Preserve exact selected artifact identity and the decoded signing facts. */ + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + retention: AnalysisViewRetention = { + evidenceById: undefined, + recordEvidence: undefined, + }, + ): Promise> { + if (options?.signal?.aborted) + return err( + new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), + ); + const input = inspectApkSignatureInputSchema.safeParse(rawInput); + if (!input.success) + return err( + analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, { + cause: input.error, + }), + ); + if (!isAbsolute(input.data.path)) + return err( + new AnalysisInputError(OPERATION, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: "Expected an absolute filesystem path on this host.", + }, + ]), + ); + const { detail, view, ...parameters } = input.data; + if (detail === "summary") { + const ready = requireAnalysisViewRetention(OPERATION, retention); + if (!ready.ok) return ready; + } + const inspected = await this.provider.inspect(parameters, options); + if (!inspected.ok) return inspected; + const execution = inspected.value; + const report = apkSignatureResultSchema.safeParse(execution.result); + if ( + !report.success || + report.data.artifact.path !== input.data.path || + !apkSignatureMatchesSubject(report.data.artifact, execution.subject) + ) + return err( + new AnalysisOutputError( + OPERATION, + "APK signature reader returned malformed data or changed the selected artifact identity.", + ), + ); + const completed = completeProviderEvidence( + createEvidence(execution.subject, execution.provider, { + operation: OPERATION, + parameters: { path: input.data.path }, + result: report.data, + ...(execution.rawResult === null + ? {} + : { rawResult: execution.rawResult }), + confidence: "observed", + locations: execution.locations, + limitations: execution.limitations, + }), + options?.signal, + ); + if (!completed.ok) return completed; + return detail === "summary" + ? projectRetainedAnalysis( + completed.value, + retention, + view === undefined + ? { kind: "summary" } + : { kind: "apk-signature", ...view }, + ) + : completed; + } +} diff --git a/src/cli.ts b/src/cli.ts index 758db31b1d..224a4ef8fd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,6 +3,7 @@ import { registerNativeFunctionMatchingCommands } from "./cli/nativeFunctionMatc import { registerCaptureCommands } from "./cli/captureCommands.js"; import { registerSignalCommands } from "./cli/signalCommands.js"; import { registerWasmCommands } from "./cli/wasmCommands.js"; +import { registerApkSignatureCommands } from "./cli/apkSignatureCommands.js"; import { snapshotEnvironment } from "./process/snapshotEnvironment.js"; import { createDirectAnalysis } from "./composition/directAnalysis.js"; import { registerEvmCommands } from "./cli/evmCommands.js"; @@ -132,6 +133,7 @@ export const createCli = ( ); registerUtilityCommands(cli, logger, environment, analysis); registerWasmCommands(cli, logger, environment); + registerApkSignatureCommands(cli, logger, environment); registerCaptureCommands(cli, logger, environment); registerSignalCommands(cli, logger, environment); registerEvmCommands(cli, logger, environment); diff --git a/src/cli/apkSignatureCommands.ts b/src/cli/apkSignatureCommands.ts new file mode 100644 index 0000000000..0b3896d927 --- /dev/null +++ b/src/cli/apkSignatureCommands.ts @@ -0,0 +1,94 @@ +import { z } from "incur"; +import type { Logger } from "pino"; +import { APK_SIGNATURE_PROVIDER_IDENTITY } from "../apkSignature/ApkSignatureRelease.js"; +import { apkSignatureModule } from "../composition/apkSignature.js"; +import { inspectApkSignatureInputSchema } from "../domain/apkSignature/apkSignatureQuery.js"; +import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; +import { safeParseJson } from "../domain/safeJson.js"; +import { logCliCommand } from "../cliLogging.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import type { CliInstance } from "./types.js"; +import { withCommandCancellation } from "./commandCancellation.js"; +import { + analysisEvidenceDeliveryOptions, + deliverCliAnalysisEvidence, +} from "./analysisEvidenceDelivery.js"; + +/** CLI shares selected delivery and complete-Evidence publication with other artifact tools. */ +export const registerApkSignatureCommands = ( + cli: CliInstance, + logger: Logger, + environment: Readonly, +): void => { + cli.command(apkSignatureModule.cliCommand, { + description: + "Inspect declared APK signing schemes, signers and content digests", + args: z.object({ path: z.string().describe("Selected local APK/ZIP/AAB") }), + options: z.object({ + ...analysisEvidenceDeliveryOptions, + view: z + .string() + .optional() + .describe( + "JSON signer/digest/lineage selection; requires --detail summary and --evidence_output", + ), + }), + run: ({ args, options }) => + withCommandCancellation((signal) => + logCliCommand(logger, apkSignatureModule.cliCommand, async () => { + const decoded = + options.view === undefined + ? { ok: true as const, value: undefined } + : safeParseJson(options.view); + if (!decoded.ok) + return projectAnalysisError( + new AnalysisInputError( + "inspect_apk_signature", + { cause: decoded.cause }, + [ + { + path: ["view"], + reason: "invalid_format", + message: "view must be a JSON APK signature selection", + }, + ], + ), + ); + const input = inspectApkSignatureInputSchema.safeParse({ + path: absoluteCallerPath(args.path), + detail: options.detail, + ...(decoded.value === undefined ? {} : { view: decoded.value }), + }); + if (!input.success) + return projectAnalysisError( + analysisInputErrorFromIssues( + "inspect_apk_signature", + input.error.issues, + { view: decoded.value }, + ), + ); + const service = apkSignatureModule.create(environment); + return deliverCliAnalysisEvidence({ + run: () => + service.inspect( + { path: input.data.path, detail: "complete" }, + { signal }, + ), + service, + providerId: APK_SIGNATURE_PROVIDER_IDENTITY.id, + operation: apkSignatureModule.contract.name, + signal, + detail: options.detail, + evidenceOutput: options.evidence_output, + ...(input.data.view === undefined + ? {} + : { + view: { kind: "apk-signature" as const, ...input.data.view }, + }), + }); + }), + ), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index de3faeb4de..74b575698b 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -7,6 +7,7 @@ export const CLI_COMMANDS = Object.freeze({ matchNativeFunctions: "match-native-functions", compareWasmInterfaces: "compare-wasm-interfaces", inspectWasmArtifact: "inspect-wasm-artifact", + inspectApkSignature: "inspect-apk-signature", inspectSqliteDatabase: "inspect-sqlite-database", inspectEvmInterface: "inspect-evm-interface", inspectWebNetworkCapture: "inspect-web-network-capture", diff --git a/src/composition/apkSignature.ts b/src/composition/apkSignature.ts new file mode 100644 index 0000000000..2a08006b4f --- /dev/null +++ b/src/composition/apkSignature.ts @@ -0,0 +1,24 @@ +import { ApkSignatureService } from "../application/apkSignature/ApkSignatureService.js"; +import { ApkSignatureProvider } from "../apkSignature/ApkSignatureProvider.js"; +import { INSPECT_APK_SIGNATURE_CONTRACT } from "../contracts/apkSignature/apkSignatureToolContracts.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; + +/** Compose the in-process APK signature reader without acquisition or import-time I/O. */ +export const createApkSignatureService = (): ApkSignatureService => + new ApkSignatureService(new ApkSignatureProvider()); + +/** Static descriptor binding the one APK-signature operation for CLI/MCP. */ +export const apkSignatureModule = { + contract: INSPECT_APK_SIGNATURE_CONTRACT, + cliCommand: CLI_COMMANDS.inspectApkSignature, + create( + _environment: Readonly, + injected?: ApkSignatureService, + ): Pick { + const service = injected ?? createApkSignatureService(); + return { + inspect: service.inspect.bind(service), + close: service.close.bind(service), + }; + }, +} as const; diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index 7a5bb95ca9..287fd4b9cf 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Inspect selected views of completed Frida session status, GDB/objdump command observations, Go build metadata, Rust embedded dependencies, binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata, inspect_model_artifact, inspect_gpu_code_objects or inspect_web_network_capture Evidence without repeating analysis. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types. Select indices, fields and pages; named rows accept names, types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses accept addresses/states, preserving every folded alternative. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Inspect selected views of completed Frida session status, GDB/objdump command observations, Go build metadata, Rust embedded dependencies, binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata, inspect_model_artifact, inspect_gpu_code_objects or inspect_web_network_capture Evidence without repeating analysis. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. APK signing views use kind apk-signature and collection signers/digests/lineage, with ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty. Declared identities and digest matches do not establish signature verification or authenticity. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types. Select indices, fields and pages; named rows accept names, types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses accept addresses/states, preserving every folded alternative. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/contracts/apkSignature/apkSignatureToolContracts.ts b/src/contracts/apkSignature/apkSignatureToolContracts.ts new file mode 100644 index 0000000000..16247e0bff --- /dev/null +++ b/src/contracts/apkSignature/apkSignatureToolContracts.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; +import { apkSignatureResultSchema } from "../../domain/apkSignature/apkSignatureReport.js"; +import { inspectApkSignatureInputSchema } from "../../domain/apkSignature/apkSignatureQuery.js"; +import { + apkSignaturePageViewResultSchema, + apkSignatureSummaryViewResultSchema, +} from "../../domain/analysisView/analysisView.js"; +import type { ToolContract } from "../toolContractTypes.js"; +import { toolContractMetadata } from "../toolEffects.js"; +import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; + +/** Static APK signature-scheme inspection of a selected local archive. */ +export const INSPECT_APK_SIGNATURE_CONTRACT = { + name: "inspect_apk_signature", + ...toolContractMetadata("inspect_apk_signature"), + kind: "artifact-provider", + description: + "Inspect declared v1/v2/v3/v3.1 APK signers, certificate fingerprints, algorithm IDs, content digests, SDK ranges, rotation lineage and source stamp without executing Android code. SHA-256 identifies the exact local file. Summary retains complete Evidence; an optional view selects signers/digests/lineage using stable IDs, scheme or signer-ID batches, fields and paging. Read retained pages with inspect_analysis_view kind apk-signature after the source is removed. IDs include artifact digest, scheme and source ordinal; certificates retain raw-DER SHA-256. Content-digest equality is not cryptographic signature verification: modified declarations can match modified bytes. Authenticity, signatures, lineage trust and v1 entry coverage remain unverified. Malformed structures and resource refusals are explicit; no device, network or SDK is used by REA.", + inputSchema: inspectApkSignatureInputSchema, + outputSchema: evidenceResultOf( + z.union([ + apkSignatureResultSchema, + apkSignatureSummaryViewResultSchema, + apkSignaturePageViewResultSchema, + ]), + ), + examples: [ + { + title: "Identify who signed an APK and under which schemes", + input: { path: "/artifacts/app.apk" }, + }, + ], +} as const satisfies ToolContract; + +/** Keep the public catalog in its existing order, using its named contract. */ +export const APK_SIGNATURE_TOOL_CONTRACTS = [ + INSPECT_APK_SIGNATURE_CONTRACT, +] as const; diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index a6c6cda087..3f8f821ea4 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -2,6 +2,7 @@ import { GPU_TOOL_CONTRACTS } from "./gpu/gpuToolContracts.js"; import { CAPTURE_TOOL_CONTRACTS } from "./captures/captureToolContracts.js"; import { SIGNAL_TOOL_CONTRACTS } from "./signals/signalToolContracts.js"; import { WASM_TOOL_CONTRACTS } from "./wasm/wasmToolContracts.js"; +import { APK_SIGNATURE_TOOL_CONTRACTS } from "./apkSignature/apkSignatureToolContracts.js"; import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js"; import { DESKTOP_APPLICATION_TOOL_CONTRACTS } from "./desktopApplicationToolContracts.js"; import { GO_TOOL_CONTRACTS } from "./go/goToolContracts.js"; @@ -59,6 +60,7 @@ export const TOOL_CONTRACTS = [ ...PYTHON_PYC_TOOL_CONTRACTS, ...ERLANG_BEAM_TOOL_CONTRACTS, ...WASM_TOOL_CONTRACTS, + ...APK_SIGNATURE_TOOL_CONTRACTS, ...SQLITE_TOOL_CONTRACTS, ...CAPTURE_TOOL_CONTRACTS, ...SIGNAL_TOOL_CONTRACTS, diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index b5509c4ccd..0b9a19debd 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -101,6 +101,7 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { inspect_adb_package: "reports install times without changing the device", list_adb_processes: "lists running processes without changing the device", inspect_web_event_listeners: "states it does not evaluate JavaScript", + inspect_apk_signature: "states that no Android code executes", }; const declaresForeignAuthority = (name: string): boolean => { diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 3d7e7ed062..46bc1a4a95 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -131,6 +131,7 @@ export const TOOL_EFFECTS: Readonly> = { inspect_model_artifact: evidence, inspect_gpu_code_objects: evidence, inspect_rust_binary: evidence, + inspect_apk_signature: evidence, inspect_python_pyc: evidence, inspect_erlang_beam: evidence, inspect_sqlite_database: effects({ diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 916dfdeb7d..966dd70a89 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -1,3 +1,12 @@ +import { + apkSignatureViewRequestSchema, + apkSignatureViewItemsSchema, +} from "../apkSignature/apkSignatureQuery.js"; +import { + projectApkSignatureView, + apkSignatureSummarySchema, + apkSignatureSourceCoverageSchema, +} from "./apkSignatureView.js"; import { byteEntropySchema } from "../native/byteEntropy.js"; import { byteEntropySummarySchema, @@ -347,6 +356,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ pcapViewRequestSchema, dartAotViewRequestSchema, il2cppMetadataViewRequestSchema, + apkSignatureViewRequestSchema, modelArtifactViewRequestSchema, gpuCodeObjectsViewRequestSchema, ]); @@ -683,6 +693,20 @@ export const gpuCodeObjectsPageViewResultSchema = z.strictObject({ parent_operation: z.literal("inspect_gpu_code_objects"), }); +export const apkSignatureSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + parent_operation: z.literal("inspect_apk_signature"), + summary: apkSignatureSummarySchema, +}); +export const apkSignaturePageViewResultSchema = z.strictObject({ + ...viewResultBase, + kind: z.literal("apk-signature"), + parent_operation: z.literal("inspect_apk_signature"), + view: apkSignatureViewRequestSchema, + items: apkSignatureViewItemsSchema, + source_coverage: apkSignatureSourceCoverageSchema, +}); + export const modelArtifactSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, parent_operation: z.literal("inspect_model_artifact"), @@ -804,6 +828,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ pcapCaptureSummaryViewResultSchema, dartAotSummaryViewResultSchema, il2cppMetadataSummaryViewResultSchema, + apkSignatureSummaryViewResultSchema, modelArtifactSummaryViewResultSchema, gpuCodeObjectsSummaryViewResultSchema, nativeInventorySummaryViewResultSchema, @@ -955,6 +980,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ ...dartAotViewContextSchema.shape, parent_operation: z.literal("inspect_dart_aot"), }), + apkSignaturePageViewResultSchema, modelArtifactPageViewResultSchema, gpuCodeObjectsPageViewResultSchema, z.strictObject({ @@ -1086,6 +1112,7 @@ const retainedViewProjectors = new Map([ ["inspect_android_package", projectAndroidPackageView], ["inspect_dart_aot", projectDartAotView], ["inspect_il2cpp_metadata", projectIl2cppMetadataView], + ["inspect_apk_signature", projectApkSignatureView], ["inspect_model_artifact", projectModelArtifactView], ["inspect_gpu_code_objects", projectGpuCodeObjectsView], ["match_native_functions", projectNativeFunctionMatchingView], diff --git a/src/domain/analysisView/apkSignatureView.ts b/src/domain/analysisView/apkSignatureView.ts new file mode 100644 index 0000000000..dfe8f9bbb9 --- /dev/null +++ b/src/domain/analysisView/apkSignatureView.ts @@ -0,0 +1,338 @@ +import { digestSchema } from "../digests.js"; +import { z } from "zod"; +import { + apkSignatureResultSchema, + apkSignatureUninterpretedBytes, + type ApkSignatureResult, +} from "../apkSignature/apkSignatureReport.js"; +import { + apkSignatureSchemeSchema, + apkSignatureViewItemsSchema, + type apkSignatureViewRequestSchema, +} from "../apkSignature/apkSignatureQuery.js"; +import { isImmutableJsonSnapshot } from "../immutableJson.js"; +import type { JsonValue } from "../jsonValue.js"; +import { err, ok, type Result } from "../result.js"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import type { + AnalysisViewParent, + AnalysisViewRequest, + UnsignedAnalysisView, +} from "./analysisView.js"; +import { parseAnalysisViewResult } from "./analysisViewParent.js"; +import { analysisViewInputError } from "./analysisViewErrors.js"; +import { + boundedViewMessages, + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; + +const identityFields = new Set([ + "id", + "scheme", + "index", + "signer_id", + "signer_index", + "certificate_sha256", +]); +const schemes = ["v1", "v2", "v3", "v3.1"] as const; +type Scheme = (typeof schemes)[number]; +type Selection = z.infer; +const signerId = ( + report: ApkSignatureResult, + scheme: Scheme, + index: number, +): string => `apk:${report.artifact.sha256}:${scheme}:signer:${String(index)}`; +const recordId = ( + signer: string, + collection: "digests" | "lineage", + index: number, +): string => `${signer}:${collection}:${String(index)}`; + +export const apkSignatureSourceCoverageSchema = z.strictObject({ + scope: z.literal("declared-signing-records"), + uninterpreted_bytes: z.int().nonnegative(), + signer_identity: z.enum(["complete-within-records", "partial"]), + lineage_decoding: z.enum(["complete-within-records", "partial"]), + signature_verification: z.literal("not-performed"), + v1_entry_coverage: z.literal("unknown"), + authenticity: z.literal("unknown"), +}); +export const apkSignatureSummarySchema = z.strictObject({ + artifact: apkSignatureResultSchema.shape.artifact, + schemes: z.record( + apkSignatureSchemeSchema, + z.strictObject({ present: z.boolean(), signers: z.int().nonnegative() }), + ), + signers: z.array( + z.strictObject({ + id: z.string(), + scheme: apkSignatureSchemeSchema, + index: z.int().nonnegative(), + certificate_sha256: digestSchema.nullable(), + }), + ), + counts: z.strictObject({ + signers: z.int().nonnegative(), + digests: z.int().nonnegative(), + lineage: z.int().nonnegative(), + }), + source_stamp: apkSignatureResultSchema.shape.source_stamp + .omit({ certificate: true }) + .extend({ certificate_sha256: digestSchema.nullable() }), + posture: apkSignatureResultSchema.shape.posture, + source_coverage: apkSignatureSourceCoverageSchema, +}); +interface Checked { + readonly report: ApkSignatureResult; + readonly summary: z.infer; + readonly unknowns: readonly string[]; +} +const checked = new WeakMap(); +const read = (parent: AnalysisViewParent): Result => { + const key = parent.normalizedResult; + const reusable = + parent.sealedResult === true && + key !== null && + typeof key === "object" && + isImmutableJsonSnapshot(key); + const cached = reusable ? checked.get(key) : undefined; + let report = cached?.report; + if (report === undefined) { + const parsed = parseAnalysisViewResult(apkSignatureResultSchema, parent); + if (!parsed.ok) return parsed; + report = reusable ? (key as ApkSignatureResult) : parsed.value; + } + // Parent identity is checked on every read, independently of reusable facts. + if ( + parent.subject != null && + (parent.subject.local_path !== report.artifact.path || + parent.subject.digest.sha256 !== report.artifact.sha256) + ) + return err( + analysisViewInputError([ + { + path: ["source", "subject"], + reason: "invalid_value", + message: "APK subject does not match its recorded artifact", + }, + ]), + ); + if (cached !== undefined) return ok(cached); + const unknowns = [ + "Declared signer and rotation identities are unverified; authenticity and v1 entry coverage remain unknown.", + ]; + if (report.source_stamp.decode_error !== null) + unknowns.push(`Source stamp: ${report.source_stamp.decode_error}`); + const identities: z.infer["signers"] = []; + let digests = 0, + lineage = 0, + unknownIdentity = false, + unknownLineage = false; + for (const scheme of schemes) { + for (const [index, signer] of report.schemes[scheme].signers.entries()) { + identities.push({ + id: signerId(report, scheme, index), + scheme, + index, + certificate_sha256: signer.certificate?.sha256_fingerprint ?? null, + }); + unknownIdentity ||= signer.certificate === null; + if (signer.certificate === null) + unknowns.push( + `${scheme} signer ${String(index)} has no resolved declared certificate identity.`, + ); + if ("decode_error" in signer && signer.decode_error !== null) + unknowns.push( + `${scheme} signer ${String(index)}: ${String(signer.decode_error)}`, + ); + if ("digests" in signer) digests += signer.digests.length; + if (scheme === "v3" || scheme === "v3.1") { + const rotation = report.schemes[scheme].signers[index]!.rotation; + lineage += rotation.lineage.length; + unknownLineage ||= rotation.decode_error !== null; + if (rotation.decode_error !== null) + unknowns.push( + `${scheme} signer ${String(index)} lineage: ${rotation.decode_error}`, + ); + } + } + } + const summary = apkSignatureSummarySchema.parse({ + artifact: report.artifact, + schemes: Object.fromEntries( + schemes.map((scheme) => [ + scheme, + { + present: report.schemes[scheme].present, + signers: report.schemes[scheme].signers.length, + }, + ]), + ), + signers: identities, + counts: { signers: identities.length, digests, lineage }, + source_stamp: { + present: report.source_stamp.present, + certificate_sha256: + report.source_stamp.certificate?.sha256_fingerprint ?? null, + decode_error: report.source_stamp.decode_error, + }, + posture: report.posture, + source_coverage: { + scope: "declared-signing-records", + uninterpreted_bytes: apkSignatureUninterpretedBytes(report), + signer_identity: unknownIdentity ? "partial" : "complete-within-records", + lineage_decoding: unknownLineage ? "partial" : "complete-within-records", + signature_verification: "not-performed", + v1_entry_coverage: "unknown", + authenticity: "unknown", + }, + }); + const value = { report, summary, unknowns }; + if (reusable) checked.set(key, value); + return ok(value); +}; + +/** Borrow canonical parsed facts; allocate rows only as this selected collection is visited. */ +function* records( + report: ApkSignatureResult, + selection: Selection, +): Generator> { + const wanted = + selection.schemes === undefined ? undefined : new Set(selection.schemes); + for (const scheme of schemes) { + if (wanted !== undefined && !wanted.has(scheme)) continue; + for (const [signerIndex, signer] of report.schemes[ + scheme + ].signers.entries()) { + const id = signerId(report, scheme, signerIndex); + if (selection.collection === "signers") { + yield { + id, + scheme, + index: signerIndex, + certificate: signer.certificate, + uninterpreted_signed_data_bytes: + "uninterpreted_signed_data_bytes" in signer + ? signer.uninterpreted_signed_data_bytes + : null, + uninterpreted_signer_bytes: + "uninterpreted_signer_bytes" in signer + ? signer.uninterpreted_signer_bytes + : null, + certificate_sha256: signer.certificate?.sha256_fingerprint ?? null, + signatures: "signatures" in signer ? signer.signatures : [], + public_key_bytes: + "public_key_bytes" in signer ? signer.public_key_bytes : null, + min_sdk: "min_sdk" in signer ? signer.min_sdk : null, + max_sdk: "max_sdk" in signer ? signer.max_sdk : null, + signature_file: + "signature_file" in signer ? signer.signature_file : null, + block_file: "block_file" in signer ? signer.block_file : null, + signer_infos: "signer_infos" in signer ? signer.signer_infos : [], + decode_error: "decode_error" in signer ? signer.decode_error : null, + }; + } else if (selection.collection === "digests" && "digests" in signer) { + for (const [index, digest] of signer.digests.entries()) + yield { + ...digest, + id: recordId(id, "digests", index), + scheme, + index, + signer_id: id, + signer_index: signerIndex, + }; + } else if ( + selection.collection === "lineage" && + (scheme === "v3" || scheme === "v3.1") + ) { + for (const [index, node] of report.schemes[scheme].signers[ + signerIndex + ]!.rotation.lineage.entries()) + yield { + ...node, + certificate_sha256: node.certificate?.sha256_fingerprint ?? null, + id: recordId(id, "lineage", index), + scheme, + index, + signer_id: id, + signer_index: signerIndex, + previous_id: + index === 0 ? null : recordId(id, "lineage", index - 1), + }; + } + } + } +} + +export const projectApkSignatureView = ( + parent: AnalysisViewParent, + view: AnalysisViewRequest, +): Result => { + if (view.kind !== "summary" && view.kind !== "apk-signature") + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "APK Evidence supports summary and apk-signature signer/digest/lineage pages", + }, + ]), + ); + const parsed = read(parent); + if (!parsed.ok) return parsed; + const { report, summary } = parsed.value; + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: "inspect_apk_signature" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: { path: report.artifact.path, sha256: report.artifact.sha256 }, + limitations: boundedViewMessages( + parent.limitations, + parent.evidenceId, + "limitations", + ), + unknowns: boundedViewMessages( + parsed.value.unknowns, + parent.evidenceId, + "unknowns", + ), + }; + if (view.kind === "summary") + return ok({ + kind: "summary", + view, + ...shared, + summary, + coverage: completeWithinViewCoverage(1, 1), + }); + const ids = view.ids === undefined ? undefined : new Set(view.ids); + const signers = + view.signer_ids === undefined ? undefined : new Set(view.signer_ids); + const fields = + view.fields === undefined ? undefined : new Set(view.fields); + const items: Record[] = []; + let total = 0; + const rows = + ids?.size === 0 || signers?.size === 0 ? [] : records(report, view); + for (const row of rows) { + if (ids !== undefined && !ids.has(String(row.id))) continue; + if (signers !== undefined && !signers.has(String(row.signer_id ?? row.id))) + continue; + if (total++ < view.offset || items.length >= view.limit) continue; + const item: Record = {}; + for (const [key, value] of Object.entries(row)) + if (identityFields.has(key) || fields === undefined || fields.has(key)) + item[key] = value; + items.push(item); + } + return ok({ + kind: "apk-signature", + view, + ...shared, + source_coverage: summary.source_coverage, + items: apkSignatureViewItemsSchema.parse(items), + coverage: pageViewCoverage(view.offset, items.length, total), + }); +}; diff --git a/src/domain/apkSignature/apkContentDigest.test.ts b/src/domain/apkSignature/apkContentDigest.test.ts new file mode 100644 index 0000000000..e2fb45cd27 --- /dev/null +++ b/src/domain/apkSignature/apkContentDigest.test.ts @@ -0,0 +1,145 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { + computeApkContentDigest, + type HashFactory, +} from "./apkContentDigest.js"; +import { parseZipTail } from "./apkSigningBlock.js"; + +const nodeHash: HashFactory = (algorithm) => { + const hash = createHash(algorithm); + return { + update: (data) => { + hash.update(data); + }, + digest: () => new Uint8Array(hash.digest()), + }; +}; + +const u16 = (v: number): number[] => [v & 0xff, (v >> 8) & 0xff]; +const u32 = (v: number): number[] => [ + v & 0xff, + (v >> 8) & 0xff, + (v >> 16) & 0xff, + (v >>> 24) & 0xff, +]; +const u64 = (v: number): number[] => [...u32(v), ...u32(0)]; + +const eocd = (cdSize: number, cdOffset: number): number[] => [ + ...u32(0x06054b50), + ...u16(0), + ...u16(0), + ...u16(1), + ...u16(1), + ...u32(cdSize), + ...u32(cdOffset), + ...u16(0), +]; + +/** 1.5 MiB of deterministic "entry" bytes, so the first section spans two chunks. */ +const ENTRIES = Uint8Array.from( + { length: 1024 * 1024 + 512 * 1024 }, + (_, i) => (i * 31 + 7) & 0xff, +); +const CD = [0x50, 0x4b, 0x01, 0x02, 1, 2, 3, 4, 5, 6]; + +/** A signing block holding one opaque pair (contents must not affect the digest). */ +const signingBlock = (payload: readonly number[]): number[] => { + const pair = [...u64(4 + payload.length), ...u32(0x7109871a), ...payload]; + const size = pair.length + 8 + 16; + return [ + ...u64(size), + ...pair, + ...u64(size), + ...[..."APK Sig Block 42"].map((c) => c.charCodeAt(0)), + ]; +}; + +const withBlock = (payload: readonly number[] = [9, 9, 9, 9]): Uint8Array => { + const block = signingBlock(payload); + const cdOffset = ENTRIES.length + block.length; + const out = new Uint8Array(cdOffset + CD.length + 22); + out.set(ENTRIES, 0); + out.set(block, ENTRIES.length); + out.set(CD, cdOffset); + out.set(eocd(CD.length, cdOffset), cdOffset + CD.length); + return out; +}; + +const withoutBlock = (): Uint8Array => { + const cdOffset = ENTRIES.length; + const out = new Uint8Array(cdOffset + CD.length + 22); + out.set(ENTRIES, 0); + out.set(CD, cdOffset); + out.set(eocd(CD.length, cdOffset), cdOffset + CD.length); + return out; +}; + +const digestOf = ( + bytes: Uint8Array, + blockStart: number, + algorithm: "sha256" | "sha512" = "sha256", +): Uint8Array | null => { + const tail = parseZipTail(bytes); + if (!tail.ok) throw new Error("bad fixture"); + return computeApkContentDigest( + bytes, + { tail: tail.value, blockStart, algorithm }, + nodeHash, + ); +}; + +describe("computeApkContentDigest", () => { + it("equals the digest of the same APK with the signing block removed", () => { + for (const algorithm of ["sha256", "sha512"] as const) { + const signed = digestOf(withBlock(), ENTRIES.length, algorithm); + const stripped = digestOf(withoutBlock(), ENTRIES.length, algorithm); + expect(signed).not.toBeNull(); + expect(signed).toEqual(stripped); + } + }); + + it("ignores the signing block's own contents", () => { + expect(digestOf(withBlock([1, 2, 3, 4]), ENTRIES.length)).toEqual( + digestOf(withBlock([5, 6, 7, 8]), ENTRIES.length), + ); + }); + + it("changes when a ZIP entry byte changes", () => { + const tampered = withBlock(); + tampered[1234] = (tampered[1234] ?? 0) ^ 0xff; + expect(digestOf(tampered, ENTRIES.length)).not.toEqual( + digestOf(withBlock(), ENTRIES.length), + ); + }); + + it("matches for a Node Buffer at a non-zero byteOffset and never mutates its input", () => { + // Regression: readStableArtifact yields a Buffer; Buffer#slice is a view, so + // rewriting the EOCD through it corrupted byte 16 and skipped the rewrite. + const plain = withBlock(); + const pooled = Buffer.alloc(plain.length + 64); + const view = pooled.subarray(64); // non-zero byteOffset over a shared ArrayBuffer + view.set(plain); + const before = Buffer.from(view); + expect(digestOf(view, ENTRIES.length)).toEqual( + digestOf(withoutBlock(), ENTRIES.length), + ); + expect(Buffer.from(view).equals(before)).toBe(true); + }); + + it("reports not-computed for a layout with a gap before the EOCD", () => { + const bytes = withBlock(); + const tail = parseZipTail(bytes); + if (!tail.ok) throw new Error("bad fixture"); + const gapped = { ...tail.value, centralDirectorySize: 3 }; // CD no longer abuts EOCD + expect( + computeApkContentDigest( + bytes, + { tail: gapped, blockStart: ENTRIES.length, algorithm: "sha256" }, + nodeHash, + ), + ).toBeNull(); + }); +}); diff --git a/src/domain/apkSignature/apkContentDigest.ts b/src/domain/apkSignature/apkContentDigest.ts new file mode 100644 index 0000000000..fa814e60c1 --- /dev/null +++ b/src/domain/apkSignature/apkContentDigest.ts @@ -0,0 +1,95 @@ +/** + * Recompute the APK Signature Scheme v2/v3 content digest so a signer's + * declared digest can be compared with the current bytes. A match only shows + * that the declared digest equals the bytes; it is not a signature + * verification, because anyone who changes the bytes can recompute the digest + * and replace the declared one. + * + * The digest covers three sections, as if the APK Signing Block were absent: + * 1. ZIP entry contents: [0, blockStart) + * 2. Central directory: [centralDirectoryOffset, eocdOffset) + * 3. End of central directory, with its "central directory offset" field + * rewritten to blockStart. + * Each section is split into 1 MiB chunks; chunk digest = H(0xa5 ‖ u32le(len) ‖ chunk); + * top digest = H(0x5a ‖ u32le(chunkCount) ‖ chunkDigests…). + * + * Pure and dependency-free: the caller injects the hash implementation. Returns + * `null` when the layout is not the plain v2 shape this algorithm assumes (e.g. a + * Zip64 record between the central directory and the EOCD), so a caller reports + * "not computed" rather than a false mismatch. + */ +import type { ZipTail } from "./apkSigningBlock.js"; + +export type ContentDigestAlgorithm = "sha256" | "sha512"; + +export interface IncrementalHash { + update(data: Uint8Array): void; + digest(): Uint8Array; +} +export type HashFactory = ( + algorithm: ContentDigestAlgorithm, +) => IncrementalHash; + +const CHUNK_SIZE = 1024 * 1024; +const EOCD_CD_OFFSET_FIELD = 16; + +const u32le = (value: number): Uint8Array => { + const out = new Uint8Array(4); + new DataView(out.buffer).setUint32(0, value, true); + return out; +}; + +export const computeApkContentDigest = ( + bytes: Uint8Array, + layout: { + readonly tail: ZipTail; + readonly blockStart: number; + readonly algorithm: ContentDigestAlgorithm; + }, + createHash: HashFactory, +): Uint8Array | null => { + const { tail, blockStart, algorithm } = layout; + const { centralDirectoryOffset, centralDirectorySize, eocdOffset } = tail; + if ( + blockStart < 0 || + blockStart > centralDirectoryOffset || + centralDirectoryOffset + centralDirectorySize !== eocdOffset || + eocdOffset + 22 > bytes.length + ) + return null; + + // A true copy: on a Node Buffer `.slice()` is a view over a possibly pooled + // ArrayBuffer, so rewriting through it would corrupt the caller's bytes. The + // DataView must also honour the copy's own byteOffset. + const eocd = Uint8Array.from(bytes.subarray(eocdOffset)); + new DataView(eocd.buffer, eocd.byteOffset, eocd.byteLength).setUint32( + EOCD_CD_OFFSET_FIELD, + blockStart, + true, + ); + const sections = [ + bytes.subarray(0, blockStart), + bytes.subarray(centralDirectoryOffset, eocdOffset), + eocd, + ]; + + const chunkDigests: Uint8Array[] = []; + for (const section of sections) + for (let offset = 0; offset < section.length; offset += CHUNK_SIZE) { + const chunk = section.subarray( + offset, + Math.min(offset + CHUNK_SIZE, section.length), + ); + const hash = createHash(algorithm); + hash.update(Uint8Array.of(0xa5)); + hash.update(u32le(chunk.length)); + hash.update(chunk); + chunkDigests.push(hash.digest()); + } + + const top = createHash(algorithm); + top.update(Uint8Array.of(0x5a)); + top.update(u32le(chunkDigests.length)); + for (const digest of chunkDigests) top.update(digest); + return top.digest(); +}; diff --git a/src/domain/apkSignature/apkSignatureQuery.ts b/src/domain/apkSignature/apkSignatureQuery.ts new file mode 100644 index 0000000000..9b6c72c17a --- /dev/null +++ b/src/domain/apkSignature/apkSignatureQuery.ts @@ -0,0 +1,130 @@ +import { digestSchema } from "../digests.js"; +import { z } from "zod"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; +import { + apkSignerSchema, + apkSignerV3Schema, + apkSignatureResultSchema, +} from "./apkSignatureReport.js"; + +export const apkSignatureSchemeSchema = z.enum(["v1", "v2", "v3", "v3.1"]); +const position = z.int().nonnegative(); +const identity = { + id: z.string().min(1), + scheme: apkSignatureSchemeSchema, + index: position, +}; +const childIdentity = { + ...identity, + signer_id: z.string().min(1), + signer_index: position, +}; +export const apkSignerRowSchema = z.strictObject({ + ...identity, + certificate: apkSignerSchema.shape.certificate, + certificate_sha256: digestSchema.nullable(), + signatures: apkSignerSchema.shape.signatures, + public_key_bytes: position.nullable(), + uninterpreted_signed_data_bytes: position.nullable(), + uninterpreted_signer_bytes: position.nullable(), + min_sdk: apkSignerSchema.shape.min_sdk, + max_sdk: apkSignerSchema.shape.max_sdk, + signature_file: z.string().nullable(), + block_file: z.string().nullable(), + signer_infos: + apkSignatureResultSchema.shape.schemes.shape.v1.shape.signers.element.shape + .signer_infos, + decode_error: z.string().nullable(), +}); +export const apkDigestRowSchema = + apkSignerSchema.shape.digests.element.extend(childIdentity); +export const apkLineageRowSchema = + apkSignerV3Schema.shape.rotation.shape.lineage.element.extend({ + ...childIdentity, + previous_id: z.string().nullable(), + certificate_sha256: digestSchema.nullable(), + }); +const filters = { + ids: z.array(z.string()).optional(), + schemes: z.array(apkSignatureSchemeSchema).optional(), + signer_ids: z.array(z.string()).optional(), + offset: position.default(0), + limit: z.int().positive().default(32), +}; +const signers = z.strictObject({ + ...filters, + collection: z.literal("signers"), + fields: z.array(apkSignerRowSchema.keyof()).optional(), +}); +const digests = z.strictObject({ + ...filters, + collection: z.literal("digests"), + fields: z.array(apkDigestRowSchema.keyof()).optional(), +}); +const lineage = z.strictObject({ + ...filters, + collection: z.literal("lineage"), + fields: z.array(apkLineageRowSchema.keyof()).optional(), +}); +export const apkSignatureSelectionSchema = z.discriminatedUnion("collection", [ + signers, + digests, + lineage, +]); +export const apkSignatureViewRequestSchema = z.discriminatedUnion( + "collection", + [ + signers.extend({ kind: z.literal("apk-signature") }), + digests.extend({ kind: z.literal("apk-signature") }), + lineage.extend({ kind: z.literal("apk-signature") }), + ], +); +export const apkSignatureViewItemsSchema = z.union([ + z.array( + apkSignerRowSchema.partial().required({ + id: true, + scheme: true, + index: true, + certificate_sha256: true, + }), + ), + z.array( + apkDigestRowSchema.partial().required({ + id: true, + scheme: true, + index: true, + signer_id: true, + signer_index: true, + }), + ), + z.array( + apkLineageRowSchema.partial().required({ + id: true, + scheme: true, + index: true, + signer_id: true, + signer_index: true, + certificate_sha256: true, + }), + ), +]); +export const inspectApkSignatureInputSchema = z + .strictObject({ + path: absoluteLocalPathStringSchema.describe( + "Selected local APK/ZIP/AAB; static inspection only", + ), + detail: z.enum(["summary", "complete"]).default("summary"), + view: apkSignatureSelectionSchema.optional(), + }) + .superRefine((input, context) => { + if (input.detail === "complete" && input.view !== undefined) + context.addIssue({ + code: "custom", + path: ["view"], + message: + "view requires summary delivery; omit it for complete Evidence", + }); + }); +export type InspectApkSignatureInput = z.infer< + typeof inspectApkSignatureInputSchema +>; diff --git a/src/domain/apkSignature/apkSignatureReport.test.ts b/src/domain/apkSignature/apkSignatureReport.test.ts new file mode 100644 index 0000000000..97a6f5f617 --- /dev/null +++ b/src/domain/apkSignature/apkSignatureReport.test.ts @@ -0,0 +1,282 @@ +import { inspectApkSignatureInputSchema } from "./apkSignatureQuery.js"; +import { describe, expect, it } from "vitest"; + +import { + apkSignatureResultSchema, + deriveApkPosture, + type ApkPostureInput, + type ApkPostureLineage, + type ApkPostureSigner, +} from "./apkSignatureReport.js"; + +const base: ApkPostureInput = { + v1Present: false, + v2Present: false, + v3Present: false, + v31Present: false, + sourceStampPresent: false, + blockSchemes: [], + v1SignerFingerprints: [], + selfConsistent: null, +}; + +const absent: ApkPostureLineage = { kind: "absent" }; +const signer = ( + fingerprint: string | null, + lineage: ApkPostureLineage = absent, +): ApkPostureSigner => ({ fingerprint, lineage }); +const decoded = (...fingerprints: (string | null)[]): ApkPostureLineage => ({ + kind: "decoded", + fingerprints, +}); +/** Signers per block scheme, lowest first: v2, v3, v3.1. */ +const blocks = ( + ...schemes: (string | null | ApkPostureSigner)[][] +): ApkPostureInput["blockSchemes"] => + (["v2", "v3", "v3.1"] as const).map((scheme, index) => ({ + scheme, + signers: (schemes[index] ?? []).map((s) => + typeof s === "object" && s !== null ? s : signer(s), + ), + })); +const derive = (input: Partial) => + deriveApkPosture({ ...base, ...input }).posture; + +it("does not attribute unchanged multi-signer sets to rotation", () => { + const result = derive({ + v2Present: true, + v3Present: true, + blockSchemes: blocks( + ["aa", "bb"], + [signer("bb", decoded("aa", "bb")), "aa"], + ), + }); + expect(result.schemes_agree_on_signer).toBe(true); + expect(result.signer_change_declared_by_rotation).toBe(false); +}); + +it.each(["v3", "v3.1"] as const)( + "keeps a missing %s signer identity unknown despite a readable lineage", + (scheme) => { + const result = derive({ + v1Present: true, + v3Present: scheme === "v3", + v31Present: scheme === "v3.1", + blockSchemes: [ + { scheme, signers: [signer(null, decoded("old", "new"))] }, + ], + v1SignerFingerprints: ["old"], + }); + expect(result.v1_signers_match_block_signers).toBeNull(); + }, +); + +describe("deriveApkPosture", () => { + it("includes present empty signer sets when comparing declared schemes", () => { + expect( + derive({ + v2Present: true, + v3Present: true, + blockSchemes: blocks([], ["aa"]), + }).schemes_agree_on_signer, + ).toBe(false); + expect( + derive({ v2Present: true, blockSchemes: blocks([]) }) + .schemes_agree_on_signer, + ).toBeNull(); + }); + it("flags v1-only when only JAR signing is present", () => { + const p = derive({ v1Present: true }); + expect(p.v1_only).toBe(true); + expect(p.strongest_scheme).toBe("v1"); + expect(p.schemes_agree_on_signer).toBeNull(); + }); + + it("picks the strongest scheme and is not v1-only when v3 is present", () => { + const p = derive({ + v1Present: true, + v2Present: true, + v3Present: true, + blockSchemes: blocks(["aa"], ["aa"]), + }); + expect(p.v1_only).toBe(false); + expect(p.strongest_scheme).toBe("v3"); + expect(p.schemes_agree_on_signer).toBe(true); + }); + + it("observes different signers across schemes as disagreement", () => { + const p = derive({ + v2Present: true, + v3Present: true, + blockSchemes: blocks(["aa"], ["bb"]), + }); + expect(p.schemes_agree_on_signer).toBe(false); + }); + + it("keeps observed disagreement separate from a declared rotation", () => { + const p = derive({ + v2Present: true, + v3Present: true, + blockSchemes: blocks(["old"], [signer("new", decoded("old", "new"))]), + }); + expect(p.schemes_agree_on_signer).toBe(false); + expect(p.signer_change_declared_by_rotation).toBe(true); + expect(p.rotation_present).toBe(true); + }); + + it("does not credit rotation when a signer is missing from the lineage", () => { + const p = derive({ + v2Present: true, + v3Present: true, + blockSchemes: blocks(["evil"], [signer("new", decoded("old", "new"))]), + }); + expect(p.signer_change_declared_by_rotation).toBe(false); + }); + + it("leaves a declared signer change unknown when a lineage or identity is unknown", () => { + const undecoded: ApkPostureLineage = { kind: "undecoded" }; + const changed = { v2Present: true, v3Present: true }; + expect( + derive({ + ...changed, + blockSchemes: blocks(["old"], [signer("new", undecoded)]), + }).signer_change_declared_by_rotation, + ).toBeNull(); + expect( + derive({ + ...changed, + blockSchemes: blocks(["old"], [signer(null, decoded("old"))]), + }).signer_change_declared_by_rotation, + ).toBeNull(); + // An undecoded lineage cannot hide a change when the signers agree. + expect( + derive({ + ...changed, + blockSchemes: blocks(["same"], [signer("same", undecoded)]), + }).signer_change_declared_by_rotation, + ).toBe(false); + }); + + it("leaves agreement unknown when a signer carries no certificate", () => { + const p = derive({ + v2Present: true, + v3Present: true, + blockSchemes: blocks(["aa"], [null]), + }); + expect(p.schemes_agree_on_signer).toBeNull(); + }); + + it("compares signer sets, so an extra signer in one scheme is a disagreement", () => { + const p = derive({ + v2Present: true, + v3Present: true, + blockSchemes: blocks(["aa", "bb"], ["aa"]), + }); + expect(p.schemes_agree_on_signer).toBe(false); + const same = derive({ + v2Present: true, + v3Present: true, + blockSchemes: blocks(["bb", "aa"], ["aa", "bb"]), + }); + expect(same.schemes_agree_on_signer).toBe(true); + }); + + it("surfaces rotation, source stamp and self-consistency", () => { + const p = derive({ + v3Present: true, + sourceStampPresent: true, + blockSchemes: blocks([], [signer("aa", decoded("aa"))]), + selfConsistent: true, + }); + expect(p.rotation_present).toBe(true); + expect(p.source_stamp_present).toBe(true); + expect(p.self_consistent).toBe(true); + }); + + it("accepts only an absolute path; schema is strict", () => { + expect( + inspectApkSignatureInputSchema.safeParse({ path: "/a/b.apk" }).success, + ).toBe(true); + expect( + inspectApkSignatureInputSchema.safeParse({ path: "/a/b.apk", extra: 1 }) + .success, + ).toBe(false); + // The result schema rejects unknown keys (sanity that strictObject holds). + expect(apkSignatureResultSchema.safeParse({}).success).toBe(false); + }); +}); + +describe("deriveApkPosture v1 comparison", () => { + const v1 = ( + v1SignerFingerprints: (string | null)[], + ...schemes: (string | null | ApkPostureSigner)[][] + ) => + deriveApkPosture({ + ...base, + v1Present: true, + v2Present: (schemes[0]?.length ?? 0) > 0, + v3Present: (schemes[1]?.length ?? 0) > 0, + v31Present: (schemes[2]?.length ?? 0) > 0, + blockSchemes: blocks(...schemes), + v1SignerFingerprints, + }); + + it("compares v1 signers with the lowest block scheme that has signers", () => { + const match = (...args: Parameters) => + v1(...args).posture.v1_signers_match_block_signers; + expect(match(["aa"], ["aa"], ["aa"])).toBe(true); + // A rotated v3 signer does not hide that v1 and v2 agree. + expect( + match(["old"], ["old"], [signer("new", decoded("old", "new"))]), + ).toBe(true); + // The JAR signature naming another key is a mismatch, not agreement. + expect(match(["other"], ["aa"], ["aa"])).toBe(false); + // Without v2 or a lineage the comparison falls to the v3 signer itself. + expect(match(["aa"], [], ["aa"])).toBe(true); + expect(match(["aa", "bb"], ["aa"])).toBe(false); + // Unknown identity on either side stays unknown. + expect(match([null], ["aa"])).toBeNull(); + expect(match(["aa"], [null])).toBeNull(); + // v1-only or no v1: nothing to compare. + expect( + derive({ v1Present: true, v1SignerFingerprints: ["aa"] }) + .v1_signers_match_block_signers, + ).toBeNull(); + expect( + derive({ v2Present: true, blockSchemes: blocks(["aa"]) }) + .v1_signers_match_block_signers, + ).toBeNull(); + }); + + it("compares v1 with a v3 lineage's original certificate when v2 is absent", () => { + // Rotated v1 + v3 without v2: v1 keeps the original key, v3 the newest. + const rotated = v1(["old"], [], [signer("new", decoded("old", "new"))]); + expect(rotated.posture.v1_signers_match_block_signers).toBe(true); + expect(rotated.notes).toEqual([]); + // v1 signed with the newest key is not the lineage origin apksig requires. + expect( + v1(["new"], [], [signer("new", decoded("old", "new"))]).posture + .v1_signers_match_block_signers, + ).toBe(false); + // The same holds for v3.1 when it is the lowest scheme with signers. + expect( + v1(["old"], [], [], [signer("new", decoded("old", "mid", "new"))]).posture + .v1_signers_match_block_signers, + ).toBe(true); + }); + + it("leaves the v1 comparison unknown, with a reason, when the lineage origin is unknown", () => { + for (const lineage of [ + { kind: "undecoded" } as const, + decoded(), + decoded(null, "new"), + ]) { + const unknown = v1(["old"], [], [signer("new", lineage)]); + expect(unknown.posture.v1_signers_match_block_signers).toBeNull(); + expect(unknown.notes).toHaveLength(1); + expect(unknown.notes[0]).toMatch( + /^v1_signers_match_block_signers is null: the v3 signer's declared rotation lineage/, + ); + } + }); +}); diff --git a/src/domain/apkSignature/apkSignatureReport.ts b/src/domain/apkSignature/apkSignatureReport.ts new file mode 100644 index 0000000000..086afac2ba --- /dev/null +++ b/src/domain/apkSignature/apkSignatureReport.ts @@ -0,0 +1,449 @@ +/** + * Public contract shape for `inspect_apk_signature` and the pure signing + * posture derivation. The domain owns the schema so CLI and MCP present + * identical, validated facts. Certificate facts are plain data extracted by the + * adapter (via the platform X.509 parser); this module never parses DER itself. + */ +import { z } from "zod"; + +import type { EvidenceSubjectTarget } from "../evidence.js"; +import { digestSchema as digest } from "../digests.js"; + +/** + * A signer certificate. `sha256_fingerprint` is always SHA-256 over the raw + * embedded DER (exactly what apksig/apksigner hash); the parsed X.509 facts are + * individually null when the platform parser cannot read the DER. The whole + * object is null only when the signer carries no certificate at all. + */ +const certificate = z + .strictObject({ + sha256_fingerprint: digest, + subject: z.string().nullable(), + issuer: z.string().nullable(), + serial_number: z.string().nullable(), + /** ISO 8601. */ + not_before: z.string().nullable(), + /** ISO 8601. */ + not_after: z.string().nullable(), + public_key_algorithm: z.string().nullable(), + }) + .nullable(); + +const digestEntry = z.strictObject({ + uninterpreted_bytes: z.int().nonnegative(), + signature_algorithm_id: z.int().min(0).max(0xffffffff), + algorithm: z.enum(["sha256", "sha512", "verity-sha256", "unknown"]), + declared_digest_hex: z.string().regex(/^(?:[a-f0-9]{2})*$/), + /** + * Whether the DECLARED content digest equals the digest recomputed over the + * current bytes; null when not recomputed. Not a signature verification: + * anyone who changes the bytes can recompute and replace the declared digest. + */ + matches_apk: z.boolean().nullable(), +}); + +export const apkSignerSchema = z.strictObject({ + uninterpreted_signed_data_bytes: z.int().nonnegative(), + uninterpreted_signer_bytes: z.int().nonnegative(), + certificate, + digests: z.array(digestEntry), + signatures: z.array( + z.strictObject({ + signature_algorithm_id: z.int().min(0).max(0xffffffff), + algorithm: digestEntry.shape.algorithm, + signature_bytes: z.int().nonnegative(), + uninterpreted_bytes: z.int().nonnegative(), + }), + ), + public_key_bytes: z.int().nonnegative(), + min_sdk: z.number().int().nullable(), + max_sdk: z.number().int().nullable(), +}); + +const lineageNode = z.strictObject({ + certificate, + /** + * Raw capability flags RETAINED by this node's own certificate after the + * rotation (kept raw so unknown future bits are preserved). + */ + flags: z.number().int().nonnegative(), + capabilities: z.strictObject({ + installed_data: z.boolean(), + shared_uid: z.boolean(), + permission: z.boolean(), + rollback: z.boolean(), + auth: z.boolean(), + }), +}); + +export const apkSignerV3Schema = apkSignerSchema.extend({ + /** + * The proof-of-rotation lineage the APK DECLARES, oldest signer first. Node + * signatures are not verified. `decode_error` is set (and `lineage` empty) + * when the attribute is present but could not be decoded. + */ + rotation: z.strictObject({ + present: z.boolean(), + lineage: z.array(lineageNode), + decode_error: z.string().nullable(), + }), +}); + +/** + * A v1 (JAR) signer: one signature block, its `.SF` partner and what the + * block's PKCS#7 SignedData declares. Nothing is verified. + */ +const v1Signer = z.strictObject({ + signature_file: z.string(), + block_file: z.string(), + /** + * The certificate the block's one SignerInfo names (by issuerAndSerialNumber + * or subjectKeyIdentifier), with the same facts as a v2/v3 signer. Null when + * the block could not be decoded, has other than one SignerInfo, or its + * SignerInfo names no certificate the block carries (identity unknown). + */ + certificate, + /** Every certificate the SignedData carries, in block order. */ + certificates: z.array( + z.strictObject({ + sha256_fingerprint: digest, + subject: z.string().nullable(), + }), + ), + signer_infos: z.array( + z.strictObject({ + identified_by: z.enum(["issuer-and-serial", "subject-key-identifier"]), + /** The named certificate's fingerprint; null when none in the block matches. */ + certificate_sha256: digest.nullable(), + digest_algorithm_oid: z.string(), + signature_algorithm_oid: z.string(), + }), + ), + /** Why the block could not be read (malformed, unsupported or over a bound); never silently empty. */ + decode_error: z.string().nullable(), +}); + +const SCHEME = z.enum(["none", "v1", "v2", "v3", "v3.1"]); + +export const apkSignatureResultSchema = z.strictObject({ + artifact: z.strictObject({ + path: z.string().min(1), + sha256: digest, + bytes: z.number().int().nonnegative(), + }), + schemes: z.strictObject({ + v1: z.strictObject({ + /** Structural: META-INF signature files exist (not platform applicability). */ + present: z.boolean(), + has_manifest: z.boolean(), + signature_files: z.array(z.string()), + block_files: z.array(z.string()), + /** Signature blocks paired with their `.SF` file, in central-directory order. */ + signers: z.array(v1Signer), + }), + v2: z.strictObject({ + present: z.boolean(), + signers: z.array(apkSignerSchema), + uninterpreted_bytes: z.int().nonnegative(), + }), + v3: z.strictObject({ + present: z.boolean(), + signers: z.array(apkSignerV3Schema), + uninterpreted_bytes: z.int().nonnegative(), + }), + "v3.1": z.strictObject({ + present: z.boolean(), + signers: z.array(apkSignerV3Schema), + uninterpreted_bytes: z.int().nonnegative(), + }), + }), + /** + * The source stamp the APK declares (V2 block preferred over V1, as apksig + * does). `certificate` is the declared stamp certificate; it is null when no + * stamp is present or when `decode_error` says why it could not be read. + * Stamp signatures are not verified. + */ + source_stamp: z.strictObject({ + present: z.boolean(), + certificate, + decode_error: z.string().nullable(), + }), + /** Derived from the observed and declared facts above; not separately observed. */ + posture: z.strictObject({ + /** + * v1 present and no v2/v3/v3.1 block. The v1 X-Android-APK-Signed marker + * is not read, so this cannot distinguish a v1-only APK from one whose + * v2/v3 block was removed. + */ + v1_only: z.boolean(), + /** + * Observed: every present block scheme (v2/v3/v3.1) presents the same set + * of signer certificates. Null when no block scheme has a signer, or when + * any signer carries no certificate (identity unknown). + */ + schemes_agree_on_signer: z.boolean().nullable(), + /** + * Declared: the signers differ, but every one appears in a rotation + * lineage the APK declares. Unverified — an APK can declare any lineage. + * Null when a signer's identity is unknown, or when the signers differ and + * a declared lineage could not be decoded. + */ + signer_change_declared_by_rotation: z.boolean().nullable(), + /** + * Observed: the set of v1 signer certificates equals what the lowest + * present block scheme with signers (v2, else v3, else v3.1) expects v1 to + * carry: its signer set, or, for a v3/v3.1 signer that declares a rotation + * lineage, that lineage's first (original) certificate, as apksig requires. + * False is notable: the JAR signature names a different key. Null when v1 + * or a block scheme is absent, any signer's identity is unknown + * (including a v1 SignerInfo identified by subjectKeyIdentifier, which + * apksig cannot resolve), or a declared lineage's original certificate + * cannot be identified (a limitation then says why). + */ + v1_signers_match_block_signers: z.boolean().nullable(), + rotation_present: z.boolean(), + source_stamp_present: z.boolean(), + strongest_scheme: SCHEME, + /** + * Every recomputed declared content digest equals the current bytes; null + * when none was recomputed. Not a signature verification. + */ + self_consistent: z.boolean().nullable(), + }), + runtime_execution: z.literal("not-performed"), +}); +export type ApkSignatureResult = z.infer; + +/** Counts disjoint unparsed suffixes; it does not classify their validity or meaning. */ +export const apkSignatureUninterpretedBytes = ( + report: ApkSignatureResult, +): number => { + let total = 0; + for (const scheme of [ + report.schemes.v2, + report.schemes.v3, + report.schemes["v3.1"], + ]) { + total += scheme.uninterpreted_bytes; + for (const signer of scheme.signers) + total += + signer.uninterpreted_signed_data_bytes + + signer.uninterpreted_signer_bytes + + signer.digests.reduce( + (n, record) => n + record.uninterpreted_bytes, + 0, + ) + + signer.signatures.reduce( + (n, record) => n + record.uninterpreted_bytes, + 0, + ); + } + return total; +}; + +export const APK_SIGNATURE_LIMITATIONS: readonly string[] = [ + "Static structural inspection only. No Android code executes, no device or adb is involved, and odex/vdex/oat/cdex are not read.", + "No signature is verified: signer, rotation-lineage and source-stamp signatures are NOT cryptographically checked, and certificate chains, revocation and authenticity are NOT evaluated. Certificates and lineages are what the APK declares.", + "matches_apk and self_consistent only show that the declared content digest equals the digest of the current bytes. This is not a signature verification: anyone who changes the bytes can recompute the digest and write it into the signed data. SHA-256 identifies the exact bytes, not the producer.", + "v1 signers are what each signature block's PKCS#7 SignedData declares: its signatures, the .SF and MANIFEST.MF digests and entry coverage are NOT verified.", + "Not read: v4 .apk.idsig, verity digests (matches_apk null), and the v1 X-Android-APK-Signed marker, so v1_only cannot distinguish a v1-only APK from one whose v2/v3 block was removed.", +]; + +/** A block-scheme signer's declared rotation lineage, oldest certificate first. */ +export type ApkPostureLineage = + | { readonly kind: "absent" } + | { + readonly kind: "decoded"; + /** Per node, its certificate fingerprint; `null` when unknown. */ + readonly fingerprints: readonly (string | null)[]; + } + | { readonly kind: "undecoded" }; + +/** One block-scheme signer as the posture derivation sees it. */ +export interface ApkPostureSigner { + /** `null` for a signer that carries no certificate (identity unknown). */ + readonly fingerprint: string | null; + readonly lineage: ApkPostureLineage; +} + +export type ApkBlockScheme = "v2" | "v3" | "v3.1"; + +/** Inputs to the pure posture derivation (scheme presence + agreement facts). */ +export interface ApkPostureInput { + readonly v1Present: boolean; + readonly v2Present: boolean; + readonly v3Present: boolean; + readonly v31Present: boolean; + readonly sourceStampPresent: boolean; + /** Per present block scheme, lowest first, its signers. */ + readonly blockSchemes: readonly { + readonly scheme: ApkBlockScheme; + readonly signers: readonly ApkPostureSigner[]; + }[]; + /** Per v1 signer, the certificate its SignerInfo names; `null` when unknown. */ + readonly v1SignerFingerprints: readonly (string | null)[]; + readonly selfConsistent: boolean | null; +} + +/** The posture plus why a derived fact stayed unknown, where it is not self-evident. */ +export interface ApkPostureDerivation { + readonly posture: ApkSignatureResult["posture"]; + readonly notes: readonly string[]; +} + +const setKey = (fingerprints: readonly string[]): string => + [...new Set(fingerprints)].sort().join(","); + +/** Unknown signer identity or an undecoded lineage can hide the answer. */ +const signerChangeDeclared = ( + signerSetsDiffer: boolean | null, + signers: ReadonlySet, + lineage: ReadonlySet, + lineageUnknown: boolean, +): boolean | null => { + if (signerSetsDiffer === null) return null; + if (!signerSetsDiffer || signers.size <= 1) return false; + if ([...signers].every((fp) => lineage.has(fp))) return true; + return lineageUnknown ? null : false; +}; + +type Expected = + | { readonly ok: true; readonly fingerprint: string | null } + | { readonly ok: false; readonly reason: string }; + +/** + * The certificate v1 is expected to carry for one signer of the lowest block + * scheme. A v3/v3.1 signer that declares a lineage rotated away from the key + * v1 (and v2) keep, so apksig requires the v1 signer to be the lineage's first + * node; without a lineage it requires the v3 signer itself. + */ +const expectedV1Certificate = ( + scheme: ApkBlockScheme, + signer: ApkPostureSigner, +): Expected => { + if ( + signer.fingerprint === null || + scheme === "v2" || + signer.lineage.kind === "absent" + ) + return { ok: true, fingerprint: signer.fingerprint }; + if (signer.lineage.kind === "undecoded") + return { + ok: false, + reason: `the ${scheme} signer's declared rotation lineage could not be decoded`, + }; + const origin = signer.lineage.fingerprints[0]; + return origin === undefined || origin === null + ? { + ok: false, + reason: `the ${scheme} signer's declared rotation lineage names no readable original certificate`, + } + : { ok: true, fingerprint: origin }; +}; + +/** v1 against what the lowest block scheme with signers expects; unknown stays unknown. */ +const v1MatchesBlockSigners = ( + v1: readonly (string | null)[], + lowest: ApkPostureInput["blockSchemes"][number] | undefined, +): { readonly match: boolean | null; readonly note: string | null } => { + if (v1.length === 0 || lowest === undefined || lowest.signers.length === 0) + return { match: null, note: null }; + const expected: (string | null)[] = []; + for (const signer of lowest.signers) { + const one = expectedV1Certificate(lowest.scheme, signer); + if (!one.ok) + return { + match: null, + note: `v1_signers_match_block_signers is null: ${one.reason}, so the original certificate v1 is expected to carry is unknown.`, + }; + expected.push(one.fingerprint); + } + if (v1.includes(null) || expected.includes(null)) + return { match: null, note: null }; + return { + match: + setKey(v1 as readonly string[]) === setKey(expected as readonly string[]), + note: null, + }; +}; + +/** Derive the conservative signing posture from observed and declared scheme facts. */ +export const deriveApkPosture = ( + input: ApkPostureInput, +): ApkPostureDerivation => { + const blockPresent = input.v2Present || input.v3Present || input.v31Present; + const strongest = input.v31Present + ? "v3.1" + : input.v3Present + ? "v3" + : input.v2Present + ? "v2" + : input.v1Present + ? "v1" + : "none"; + const present = input.blockSchemes.filter(({ scheme }) => + scheme === "v2" + ? input.v2Present + : scheme === "v3" + ? input.v3Present + : input.v31Present, + ); + const sets = present.map(({ signers }) => + signers.map(({ fingerprint }) => fingerprint), + ); + // A signer without a certificate has unknown identity: agreement is unknown, + // never assumed. + const identityUnknown = sets.some((fps) => fps.includes(null)); + const known = sets.map((fps) => + fps.filter((fp): fp is string => fp !== null), + ); + const union = new Set(known.flat()); + const signerSetsDiffer = identityUnknown + ? null + : new Set(known.map(setKey)).size > 1; + const lineages = present.flatMap(({ signers }) => + signers.map(({ lineage }) => lineage), + ); + const lineage = new Set( + lineages.flatMap((l) => + l.kind === "decoded" + ? l.fingerprints.filter((fp): fp is string => fp !== null) + : [], + ), + ); + const v1Match = input.v1Present + ? v1MatchesBlockSigners(input.v1SignerFingerprints, present[0]) + : { match: null, note: null }; + return { + posture: { + v1_only: input.v1Present && !blockPresent, + schemes_agree_on_signer: + union.size === 0 || signerSetsDiffer === null + ? null + : !signerSetsDiffer, + signer_change_declared_by_rotation: signerChangeDeclared( + signerSetsDiffer, + union, + lineage, + lineages.some((l) => l.kind === "undecoded"), + ), + v1_signers_match_block_signers: v1Match.match, + rotation_present: lineages.some((l) => l.kind !== "absent"), + source_stamp_present: input.sourceStampPresent, + strongest_scheme: strongest, + self_consistent: input.selfConsistent, + }, + notes: v1Match.note === null ? [] : [v1Match.note], + }; +}; + +/** The returned artifact identity must match the caller-selected local file. */ +export const apkSignatureMatchesSubject = ( + artifact: ApkSignatureResult["artifact"], + subject: EvidenceSubjectTarget, +): boolean => + artifact.sha256 === subject.sha256 && + artifact.path === subject.path && + (subject.format === "file" || + subject.format === "apk" || + subject.format === "zip"); diff --git a/src/domain/apkSignature/apkSignatureSchemes.test.ts b/src/domain/apkSignature/apkSignatureSchemes.test.ts new file mode 100644 index 0000000000..b7419eab2f --- /dev/null +++ b/src/domain/apkSignature/apkSignatureSchemes.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from "vitest"; +import { apkSchemeValue } from "../../../tests/fixtures/apkSignature/signingScheme.js"; + +import { + contentDigestAlgorithm, + decodeRotationLineage, + decodeSigners, + MAX_LINEAGE_NODES, + MAX_SIGNERS, +} from "./apkSignatureSchemes.js"; + +const ATTR_ID = 0xbeeff00d; + +const u32 = (n: number): number[] => [ + n & 0xff, + (n >> 8) & 0xff, + (n >> 16) & 0xff, + (n >>> 24) & 0xff, +]; +/** length-prefixed chunk: uint32 LE length, then the bytes. */ +const lp = (bytes: readonly number[]): number[] => [ + ...u32(bytes.length), + ...bytes, +]; +/** length-prefixed sequence of length-prefixed elements. */ +const seq = (elements: readonly (readonly number[])[]): number[] => + lp(elements.flatMap((e) => lp(e))); + +const CERT_DER = [0x30, 0x03, 0x01, 0x02, 0x03]; // opaque placeholder; schemes don't parse it +const DIGEST = [0xde, 0xad, 0xbe, 0xef]; +const digestElement = [...u32(0x0103), ...lp(DIGEST)]; // RSA-PKCS1 SHA-256 +const attrElement = [...u32(ATTR_ID), 0x01]; + +const schemeValue = ( + signedData: readonly number[], + sdkRange?: { min: number; max: number }, +): Uint8Array => apkSchemeValue(Uint8Array.from(signedData), sdkRange); + +it.each(["missing signature records", "truncated public key"])( + "rejects a signer with %s", + (kind) => { + const signed = [...seq([digestElement]), ...seq([CERT_DER]), ...seq([])]; + const signatures = seq([[...u32(0x0103), ...lp([1, 2])]]); + const signer = + kind === "missing signature records" + ? lp(signed) + : [...lp(signed), ...signatures, ...u32(5), 1]; + expect(decodeSigners(Uint8Array.from(seq([signer])), "v2")).toMatchObject({ + ok: false, + error: { kind: "malformed-signer" }, + }); + }, +); + +it("preserves unparsed suffixes instead of refusing reader-compatible extensions", () => { + const signed = [ + ...seq([[...digestElement, 0xaa]]), + ...seq([CERT_DER]), + ...seq([]), + ...lp([]), + ]; + const signer = [ + ...lp(signed), + ...seq([[...u32(0x0103), ...lp([1]), 0xbb]]), + ...lp([1]), + 0xcc, + ]; + const result = decodeSigners(Uint8Array.from([...seq([signer]), 0xdd]), "v2"); + if (!result.ok) throw result.error; + expect(result.value.uninterpretedBytes).toBe(1); + expect(result.value.signers[0]).toMatchObject({ + uninterpretedSignedDataBytes: 4, + uninterpretedSignerBytes: 1, + }); + expect(result.value.signers[0]!.digests[0]!.uninterpretedBytes).toBe(1); + expect(result.value.signers[0]!.signatures[0]!.uninterpretedBytes).toBe(1); +}); + +it("rejects inconsistent v3 SDK ranges", () => { + const signed = [ + ...seq([digestElement]), + ...seq([CERT_DER]), + ...u32(28), + ...u32(34), + ...seq([]), + ]; + expect( + decodeSigners(schemeValue(signed, { min: 29, max: 34 }), "v3"), + ).toMatchObject({ + ok: false, + error: { + kind: "malformed-signer", + detail: expect.stringContaining("SDK range"), + }, + }); +}); + +it("preserves an unknown unsigned algorithm ID", () => { + const signed = [ + ...seq([[...u32(0xfedcba98), ...lp(DIGEST)]]), + ...seq([CERT_DER]), + ...seq([]), + ]; + const result = decodeSigners(schemeValue(signed), "v2"); + if (!result.ok) throw result.error; + expect(result.value.signers[0]!.digests[0]!.algorithmId).toBe(0xfedcba98); + expect(contentDigestAlgorithm(0xfedcba98)).toBeNull(); +}); + +describe("APK scheme signer decoder", () => { + it.each( + [ + [...lp(lp([1])), ...u32(0), ...u32(0x0103), ...lp([1])], + [...lp([...lp([1]), ...u32(0x0103)]), ...u32(0)], + [...lp([...lp([1]), ...u32(0x0103)]), ...u32(0), ...u32(0x0103)], + [ + ...lp([...lp([1]), ...u32(0x0103)]), + ...u32(0), + ...u32(0x0103), + ...u32(2), + 1, + ], + ].map((node) => ({ node })), + )( + "refuses lineage nodes missing required algorithm/signature framing", + ({ node }) => { + expect( + decodeRotationLineage(Uint8Array.from([...u32(1), ...lp(node)])), + ).toMatchObject({ ok: false, error: { kind: "malformed-signer" } }); + }, + ); + it("decodes a v2 signer's digests, certificate and attributes", () => { + const signedData = [ + ...seq([digestElement]), + ...seq([CERT_DER]), + ...seq([attrElement]), + ]; + const result = decodeSigners(schemeValue(signedData), "v2"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.signers).toHaveLength(1); + const signer = result.value.signers[0]!; + expect( + signer.digests.map(({ algorithmId, digest }) => ({ + algorithmId, + digest: [...digest], + })), + ).toEqual([{ algorithmId: 0x0103, digest: DIGEST }]); + expect( + signer.signatures.map(({ algorithmId, signature }) => ({ + algorithmId, + bytes: [...signature], + })), + ).toEqual([{ algorithmId: 0x0103, bytes: [1] }]); + expect([...signer.certificates[0]!]).toEqual(CERT_DER); + expect(signer.minSdk).toBeNull(); + expect(signer.attributes[0]!.id).toBe(ATTR_ID); + }); + + it("decodes a v3 signer's min/max SDK between certificates and attributes", () => { + const signedData = [ + ...seq([digestElement]), + ...seq([CERT_DER]), + ...u32(21), // min_sdk + ...u32(34), // max_sdk + ...seq([attrElement]), + ]; + const result = decodeSigners( + schemeValue(signedData, { min: 21, max: 34 }), + "v3", + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.signers[0]!.minSdk).toBe(21); + expect(result.value.signers[0]!.maxSdk).toBe(34); + }); + + it("refuses a truncated signer sequence", () => { + const result = decodeSigners(Uint8Array.from(u32(9999)), "v2"); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.kind).toBe("malformed-signer"); + }); + + it("names the failing signer and keeps a cap overrun distinct from malformed input", () => { + const truncated = decodeSigners( + Uint8Array.from(seq([lp([...seq([digestElement]), ...u32(99)])])), + "v3", + ); + expect(truncated.ok).toBe(false); + if (!truncated.ok) + expect(truncated.error).toMatchObject({ + kind: "malformed-signer", + detail: expect.stringContaining("signer 0") as string, + }); + const signedData = [ + ...seq([digestElement]), + ...seq([CERT_DER]), + ...seq([]), + ]; + const tooMany = decodeSigners( + Uint8Array.from( + seq(Array.from({ length: MAX_SIGNERS + 1 }, () => lp(signedData))), + ), + "v2", + ); + expect(tooMany.ok).toBe(false); + if (!tooMany.ok) + expect(tooMany.error).toEqual({ + kind: "budget-exceeded", + resource: "signers", + limit: MAX_SIGNERS, + }); + }); + + it("decodes lineage nodes with each node's own retained flags", () => { + const node = (cert: number[], flags: number): number[] => + lp([ + ...lp([...lp(cert), ...u32(0x0103)]), + ...u32(flags), + ...u32(0x0103), + ...lp([1]), + ]); + const lineage = decodeRotationLineage( + Uint8Array.from([...u32(1), ...node([1, 2], 22), ...node([3, 4], 21)]), + ); + expect(lineage.ok).toBe(true); + if (!lineage.ok) return; + expect(lineage.value.map((n) => [[...n.certificate], n.flags])).toEqual([ + [[1, 2], 22], + [[3, 4], 21], + ]); + }); + + it("refuses an unknown lineage version and an over-cap lineage", () => { + const badVersion = decodeRotationLineage(Uint8Array.from(u32(2))); + expect(badVersion.ok).toBe(false); + if (!badVersion.ok) expect(badVersion.error.kind).toBe("malformed-signer"); + const node = lp([ + ...lp([...lp([1]), ...u32(0x0103)]), + ...u32(0), + ...u32(0x0103), + ...lp([]), + ]); + const tooLong = decodeRotationLineage( + Uint8Array.from([ + ...u32(1), + ...Array.from({ length: MAX_LINEAGE_NODES + 1 }, () => node).flat(), + ]), + ); + expect(tooLong.ok).toBe(false); + if (!tooLong.ok) expect(tooLong.error.kind).toBe("budget-exceeded"); + }); + + it("maps signature-algorithm ids to content-digest algorithms", () => { + expect(contentDigestAlgorithm(0x0103)).toBe("sha256"); + expect(contentDigestAlgorithm(0x0104)).toBe("sha512"); + expect(contentDigestAlgorithm(0x0421)).toBe("verity-sha256"); + expect(contentDigestAlgorithm(0x0425)).toBe("verity-sha256"); + expect(contentDigestAlgorithm(0x9999)).toBeNull(); + }); +}); diff --git a/src/domain/apkSignature/apkSignatureSchemes.ts b/src/domain/apkSignature/apkSignatureSchemes.ts new file mode 100644 index 0000000000..8e7714a101 --- /dev/null +++ b/src/domain/apkSignature/apkSignatureSchemes.ts @@ -0,0 +1,359 @@ +/** + * Decode the signer structures inside an APK Signature Scheme v2/v3/v3.1 block + * value. Each scheme value is a little-endian, uint32-length-prefixed sequence + * of signers; a signer carries length-prefixed signed-data (digests, X.509 + * certificate DER, and — for v3/v3.1 — min/max SDK and additional attributes + * including the proof-of-rotation lineage). + * + * This frames the structure and exposes digests (by signature-algorithm id), + * certificate DER, SDK bounds, and additional attributes. Certificate facts and + * digest recomputation are layered on top. Dependency-free and bounded: a short + * or inconsistent length is a `malformed-signer` error, and a count past a + * resource admission cap is a distinct `budget-exceeded` error, never conflated. + */ +import { type Result, err, ok } from "../result.js"; + +export type ApkSchemeVersion = "v2" | "v3" | "v3.1"; + +export type ApkSchemeError = + | { + readonly kind: "malformed-signer"; + readonly detail: string; + } + | { + readonly kind: "budget-exceeded"; + readonly resource: string; + readonly limit: number; + }; + +export interface ApkSignerDigest { + /** APK signature-algorithm id (e.g. 0x0103 = RSA-PKCS1 SHA-256). */ + readonly algorithmId: number; + readonly digest: Uint8Array; + readonly uninterpretedBytes: number; +} + +export interface ApkSignerAttribute { + readonly id: number; + readonly value: Uint8Array; +} + +export interface ApkSignerSignature { + readonly algorithmId: number; + readonly signature: Uint8Array; + readonly uninterpretedBytes: number; +} + +export interface ApkSigner { + /** X.509 certificate DER blobs; the first is the signing certificate. */ + readonly certificates: readonly Uint8Array[]; + readonly digests: readonly ApkSignerDigest[]; + readonly signatures: readonly ApkSignerSignature[]; + readonly publicKey: Uint8Array; + readonly uninterpretedSignedDataBytes: number; + readonly uninterpretedSignerBytes: number; + readonly minSdk: number | null; + readonly maxSdk: number | null; + readonly attributes: readonly ApkSignerAttribute[]; +} + +/** Additional-attribute id carrying the v3 proof-of-rotation lineage. */ +export const PROOF_OF_ROTATION_ATTR_ID = 0x3ba06f8c; + +/** Generic cap on digests/certificates/attributes in one signer. */ +const MAX_ELEMENTS = 4096; +/** + * Resource admission for signer fan-out, including per-signer X.509 work. + * This is not an APK format maximum; excess records return a capacity error. + */ +export const MAX_SIGNERS = 32; +/** + * Resource admission for lineage fan-out before certificate materialisation. + * This is not a format maximum; longer declared chains are refused explicitly. + */ +export const MAX_LINEAGE_NODES = 32; +/** The only proof-of-rotation lineage encoding version apksig accepts. */ +const LINEAGE_VERSION = 1; + +/** Sequential little-endian reader over a byte range; every read is bounds-checked. */ +class LeReader { + offset = 0; + private readonly dv: DataView; + constructor(private readonly bytes: Uint8Array) { + this.dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + } + get remaining(): number { + return this.bytes.length - this.offset; + } + u32(): number | null { + if (this.remaining < 4) return null; + const v = this.dv.getUint32(this.offset, true); + this.offset += 4; + return v; + } + /** A uint32-length-prefixed chunk; returns a view over its bytes, or null if oob. */ + lenChunk(): Uint8Array | null { + const length = this.u32(); + if (length === null || length > this.remaining) return null; + const start = this.offset; + this.offset += length; + return this.bytes.subarray(start, start + length); + } +} + +const malformed = (detail: string): Result => + err({ kind: "malformed-signer", detail }); + +/** Split a length-prefixed sequence into its length-prefixed elements. */ +const elements = ( + seq: Uint8Array | null, + what: string, + limit = MAX_ELEMENTS, +): Result => { + if (seq === null) return malformed(`${what} sequence missing or truncated`); + const reader = new LeReader(seq); + const out: Uint8Array[] = []; + while (reader.remaining > 0) { + if (out.length >= limit) + return err({ kind: "budget-exceeded", resource: what, limit }); + const el = reader.lenChunk(); + if (el === null) return malformed(`${what} element truncated`); + out.push(el); + } + return ok(out); +}; + +const decodeAlgorithmRecords = ( + seq: Uint8Array | null, + what: string, + record: ( + algorithmId: number, + bytes: Uint8Array, + uninterpretedBytes: number, + ) => T, +): Result => { + const items = elements(seq, what); + if (!items.ok) return items; + const records: T[] = []; + for (const item of items.value) { + const reader = new LeReader(item); + const algorithmId = reader.u32(); + const bytes = reader.lenChunk(); + if (algorithmId === null || bytes === null) + return malformed(`${what} entry truncated`); + records.push(record(algorithmId, bytes, reader.remaining)); + } + return ok(records); +}; + +const decodeAttributes = ( + seq: Uint8Array | null, +): Result => { + const items = elements(seq, "attributes"); + if (!items.ok) return items; + const attributes: ApkSignerAttribute[] = []; + for (const item of items.value) { + const id = new LeReader(item).u32(); + if (id === null) return malformed("attribute id truncated"); + attributes.push({ id, value: item.subarray(4) }); + } + return ok(attributes); +}; + +/** Decode one signer's signed-data chunk (v3/v3.1 carry min/max SDK before attributes). */ +const decodeSignedData = ( + signedData: Uint8Array, + version: ApkSchemeVersion, +): Result< + Omit, + ApkSchemeError +> => { + const reader = new LeReader(signedData); + const digests = decodeAlgorithmRecords( + reader.lenChunk(), + "digests", + (algorithmId, digest, uninterpretedBytes) => ({ + algorithmId, + digest, + uninterpretedBytes, + }), + ); + if (!digests.ok) return digests; + const certificates = elements(reader.lenChunk(), "certificates"); + if (!certificates.ok) return certificates; + let minSdk: number | null = null; + let maxSdk: number | null = null; + if (version !== "v2") { + minSdk = reader.u32(); + maxSdk = reader.u32(); + if (minSdk === null || maxSdk === null) + return malformed("min/max SDK truncated"); + } + const attributes = decodeAttributes(reader.lenChunk()); + if (!attributes.ok) return attributes; + return ok({ + certificates: certificates.value, + digests: digests.value, + minSdk, + maxSdk, + attributes: attributes.value, + uninterpretedSignedDataBytes: reader.remaining, + }); +}; + +/** Own all signer framing, including the unsigned copy of v3 SDK bounds. */ +const decodeSigner = ( + chunk: Uint8Array, + version: ApkSchemeVersion, +): Result => { + const reader = new LeReader(chunk); + const signedData = reader.lenChunk(); + if (signedData === null) return malformed("signed data missing or truncated"); + const decoded = decodeSignedData(signedData, version); + if (!decoded.ok) return decoded; + if (version !== "v2") { + const min = reader.u32(), + max = reader.u32(); + if (min === null || max === null) + return malformed("outer min/max SDK truncated"); + if (min !== decoded.value.minSdk || max !== decoded.value.maxSdk) + return malformed("outer SDK range disagrees with the signed SDK range"); + } + const signatures = decodeAlgorithmRecords( + reader.lenChunk(), + "signatures", + (algorithmId, signature, uninterpretedBytes) => ({ + algorithmId, + signature, + uninterpretedBytes, + }), + ); + if (!signatures.ok) return signatures; + const publicKey = reader.lenChunk(); + if (publicKey === null) return malformed("public key truncated"); + return ok({ + ...decoded.value, + signatures: signatures.value, + publicKey, + uninterpretedSignerBytes: reader.remaining, + }); +}; + +export interface ApkDecodedScheme { + readonly signers: readonly ApkSigner[]; + readonly uninterpretedBytes: number; +} + +/** Decode every signer in a v2/v3/v3.1 scheme block value. */ +export const decodeSigners = ( + value: Uint8Array, + version: ApkSchemeVersion, +): Result => { + const reader = new LeReader(value); + const chunks = elements(reader.lenChunk(), "signers", MAX_SIGNERS); + if (!chunks.ok) return chunks; + const signers: ApkSigner[] = []; + for (const [index, chunk] of chunks.value.entries()) { + const signer = decodeSigner(chunk, version); + if (!signer.ok) + return signer.error.kind === "malformed-signer" + ? malformed(`signer ${String(index)}: ${signer.error.detail}`) + : signer; + signers.push(signer.value); + } + return ok({ signers, uninterpretedBytes: reader.remaining }); +}; + +/** One node of a v3 proof-of-rotation lineage (oldest first). */ +export interface ApkLineageNode { + /** X.509 certificate DER of the signer at this level. */ + readonly certificate: Uint8Array; + /** + * Capability flags RETAINED by this node's own certificate after the + * rotation (installed data, shared UID, permission, rollback, auth) — the + * per-certificate flags Android keeps in SigningDetails.pastSigningCertificates. + */ + readonly flags: number; +} + +/** + * Decode the v3 proof-of-rotation attribute value (apksig + * V3SigningCertificateLineage): `u32 version` (must be 1) then length-prefixed + * nodes, each `lp(signedData{ lp(certificate), u32 sigAlgorithm }) | u32 flags | + * u32 sigAlgorithm | lp(signature)`. Node signatures are NOT verified: this is + * the lineage the APK declares. + */ +export const decodeRotationLineage = ( + value: Uint8Array, +): Result => { + const reader = new LeReader(value); + const version = reader.u32(); + if (version === null) return malformed("lineage version missing"); + if (version !== LINEAGE_VERSION) + return malformed(`unsupported lineage version ${String(version)}`); + const nodes: ApkLineageNode[] = []; + while (reader.remaining > 0) { + if (nodes.length >= MAX_LINEAGE_NODES) + return err({ + kind: "budget-exceeded", + resource: "lineage-nodes", + limit: MAX_LINEAGE_NODES, + }); + const node = reader.lenChunk(); + if (node === null) return malformed("lineage node truncated"); + const nodeReader = new LeReader(node); + const signedData = nodeReader.lenChunk(); + const flags = nodeReader.u32(); + if (signedData === null || flags === null) + return malformed("lineage node malformed"); + const signedReader = new LeReader(signedData); + const certificate = signedReader.lenChunk(); + if (certificate === null) return malformed("lineage certificate missing"); + if (signedReader.u32() === null) + return malformed("lineage signed algorithm missing or truncated"); + if (nodeReader.u32() === null || nodeReader.lenChunk() === null) + return malformed("lineage algorithm or signature missing or truncated"); + nodes.push({ certificate, flags }); + } + return ok(nodes); +}; + +/** + * Decode the stamp certificate from a source-stamp block value (apksig + * V1/V2 SourceStampVerifier): `lp(stampData)`, where `stampData` begins with + * `lp(certificate DER)` followed by the signed digests and signatures. The + * stamp signatures are NOT verified: this is the certificate the APK declares. + */ +export const decodeSourceStampCertificate = ( + value: Uint8Array, +): Result => { + const stampData = new LeReader(value).lenChunk(); + if (stampData === null) return malformed("source stamp data truncated"); + const certificate = new LeReader(stampData).lenChunk(); + if (certificate === null || certificate.length === 0) + return malformed("source stamp certificate missing"); + return ok(certificate); +}; + +/** Map a signature-algorithm id to the content-digest algorithm it uses. */ +export const contentDigestAlgorithm = ( + algorithmId: number, +): "sha256" | "sha512" | "verity-sha256" | null => { + switch (algorithmId) { + case 0x0101: // RSASSA-PSS SHA2-256 + case 0x0103: // RSASSA-PKCS1-v1_5 SHA2-256 + case 0x0201: // ECDSA SHA2-256 + case 0x0301: // DSA SHA2-256 + return "sha256"; + case 0x0102: // RSASSA-PSS SHA2-512 + case 0x0104: // RSASSA-PKCS1-v1_5 SHA2-512 + case 0x0202: // ECDSA SHA2-512 + return "sha512"; + case 0x0421: // RSASSA-PKCS1-v1_5 SHA2-256 over the verity tree + case 0x0423: // ECDSA SHA2-256 over the verity tree + case 0x0425: // DSA SHA2-256 over the verity tree + return "verity-sha256"; + default: + return null; + } +}; diff --git a/src/domain/apkSignature/apkSigningBlock.test.ts b/src/domain/apkSignature/apkSigningBlock.test.ts new file mode 100644 index 0000000000..04d73ad804 --- /dev/null +++ b/src/domain/apkSignature/apkSigningBlock.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it } from "vitest"; + +import { + APK_BLOCK_ID, + locateApkSigningBlock, + parseZipTail, +} from "./apkSigningBlock.js"; + +/** Little-endian byte builder for a minimal APK-like tail. */ +class Bytes { + readonly b: number[] = []; + u16(v: number): this { + this.b.push(v & 0xff, (v >> 8) & 0xff); + return this; + } + u32(v: number): this { + this.b.push(v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >>> 24) & 0xff); + return this; + } + u64(v: number): this { + return this.u32(v).u32(0); // values in tests stay within 32 bits + } + raw(bytes: readonly number[]): this { + this.b.push(...bytes); + return this; + } + ascii(text: string): this { + for (const c of text) this.b.push(c.charCodeAt(0)); + return this; + } +} + +/** + * Assemble [entries][signing block][central directory][EOCD] with one V2 pair. + * Layout is computed so the EOCD points the central directory at the block end. + */ +const apkWithBlock = ( + pairs: readonly { id: number; value: readonly number[] }[], + withMagic = true, +): Uint8Array => { + const entries = new Bytes().raw([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); // filler + const pairBytes = new Bytes(); + for (const p of pairs) + pairBytes + .u64(4 + p.value.length) + .u32(p.id) + .raw(p.value); + const sizeOfBlock = + pairBytes.b.length + 8 /* trailing size */ + 16; /* magic */ + const block = new Bytes().u64(sizeOfBlock).raw(pairBytes.b).u64(sizeOfBlock); + if (withMagic) block.ascii("APK Sig Block 42"); + else block.ascii("NOT the block!!!"); // 16 bytes, wrong magic + const cdOffset = entries.b.length + block.b.length; + const cd = new Bytes().raw([0xca, 0xfe, 0xca, 0xfe]); // filler central directory + const eocd = new Bytes() + .u32(0x06054b50) + .u16(0) + .u16(0) + .u16(1) + .u16(1) + .u32(cd.b.length) + .u32(cdOffset) + .u16(0); + return Uint8Array.from([...entries.b, ...block.b, ...cd.b, ...eocd.b]); +}; + +describe("APK signing block locator", () => { + it.each([1, 22, 23, 0xffffffff])( + "refuses a directory extent overlapping or extending beyond the EOCD by %s bytes", + (extra) => { + const bytes = apkWithBlock([]); + const eocd = bytes.length - 22; + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + dv.setUint32( + eocd + 12, + extra === 0xffffffff ? extra - 1 : 4 + extra, + true, + ); + expect(parseZipTail(bytes)).toMatchObject({ + ok: false, + error: { kind: "malformed-zip" }, + }); + }, + ); + it("parses the ZIP tail to the central-directory offset", () => { + const tail = parseZipTail( + apkWithBlock([{ id: APK_BLOCK_ID.signatureV2, value: [1, 2, 3, 4] }]), + ); + expect(tail.ok).toBe(true); + if (!tail.ok) return; + expect(tail.value.centralDirectoryOffset).toBe(10 + 48); // entries + block + expect(tail.value.totalEntries).toBe(1); + }); + + it("locates the signing block and frames its id-value pairs", () => { + const bytes = apkWithBlock([ + { id: APK_BLOCK_ID.signatureV2, value: [0xaa, 0xbb, 0xcc, 0xdd] }, + { id: APK_BLOCK_ID.signatureV3, value: [0x11, 0x22] }, + ]); + const tail = parseZipTail(bytes); + expect(tail.ok).toBe(true); + if (!tail.ok) return; + const block = locateApkSigningBlock(bytes, tail.value); + expect(block.ok).toBe(true); + if (!block.ok || block.value === null) return expect(block).toBeTruthy(); + expect(block.value.pairs.map((p) => p.id)).toEqual([ + APK_BLOCK_ID.signatureV2, + APK_BLOCK_ID.signatureV3, + ]); + expect([...block.value.pairs[0]!.value]).toEqual([0xaa, 0xbb, 0xcc, 0xdd]); + }); + + it("returns null when no block magic precedes the central directory", () => { + const bytes = apkWithBlock( + [{ id: APK_BLOCK_ID.signatureV2, value: [1, 2, 3, 4] }], + false, + ); + const tail = parseZipTail(bytes); + expect(tail.ok).toBe(true); + if (!tail.ok) return; + const block = locateApkSigningBlock(bytes, tail.value); + expect(block.ok).toBe(true); + if (!block.ok) return; + expect(block.value).toBeNull(); + }); + + it("refuses a buffer with no end-of-central-directory record", () => { + const result = parseZipTail(Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8])); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.kind).toBe("not-a-zip"); + }); + + it("calls a ZIP with local headers but no EOCD damaged, not foreign", () => { + const bytes = apkWithBlock([ + { id: APK_BLOCK_ID.signatureV2, value: [1, 2, 3, 4] }, + ]); + bytes.set([0x50, 0x4b, 0x03, 0x04], 0); + const result = parseZipTail(bytes.subarray(0, bytes.length - 22)); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.kind).toBe("malformed-zip"); + }); + + it("refuses a block footer smaller than its own 24 bytes", () => { + const bytes = apkWithBlock([ + { id: APK_BLOCK_ID.signatureV2, value: [1, 2, 3, 4] }, + ]); + const tail = parseZipTail(bytes); + expect(tail.ok).toBe(true); + if (!tail.ok) return; + // Trailing size sits 24 bytes before the central directory. + const at = tail.value.centralDirectoryOffset - 24; + bytes.set([16, 0, 0, 0, 0, 0, 0, 0], at); + const block = locateApkSigningBlock(bytes, tail.value); + expect(block.ok).toBe(false); + if (block.ok) return; + expect(block.error).toMatchObject({ + kind: "malformed-block", + detail: "bad trailing size", + }); + }); + + it("flags a leading/trailing size mismatch as malformed", () => { + const bytes = apkWithBlock([ + { id: APK_BLOCK_ID.signatureV2, value: [1, 2, 3, 4] }, + ]); + // Corrupt the leading size (first byte of the block, after 10 filler bytes). + bytes[10] = 0xff; + const tail = parseZipTail(bytes); + expect(tail.ok).toBe(true); + if (!tail.ok) return; + const block = locateApkSigningBlock(bytes, tail.value); + expect(block.ok).toBe(false); + if (block.ok) return; + expect(block.error.kind).toBe("malformed-block"); + }); +}); + +describe("Zip64 end-of-central-directory", () => { + it("follows a Zip64 EOCD locator to the central-directory offset", () => { + const body = new Bytes().raw(Array.from({ length: 40 }, () => 0)); + const cdOffset = body.b.length; + const cd = new Bytes().raw([0xca, 0xfe, 0xca, 0xfe]); + const recordOffset = cdOffset + cd.b.length; + const record = new Bytes() + .u32(0x06064b50) + .u64(44) // size of remaining record + .u16(45) + .u16(45) + .u32(0) + .u32(0) + .u64(1) + .u64(1) + .u64(cd.b.length) // offset 40: CD size + .u64(cdOffset); // offset 48: CD offset + const locator = new Bytes().u32(0x07064b50).u32(0).u64(recordOffset).u32(1); + const eocd = new Bytes() + .u32(0x06054b50) + .u16(0) + .u16(0) + .u16(0xffff) + .u16(0xffff) + .u32(0xffffffff) + .u32(0xffffffff) + .u16(0); + const tail = parseZipTail( + Uint8Array.from([ + ...body.b, + ...cd.b, + ...record.b, + ...locator.b, + ...eocd.b, + ]), + ); + expect(tail.ok).toBe(true); + if (!tail.ok) return; + expect(tail.value.centralDirectoryOffset).toBe(cdOffset); + expect(tail.value.centralDirectorySize).toBe(cd.b.length); + // Entry count comes from the Zip64 record, not the saturated EOCD field. + expect(tail.value.totalEntries).toBe(1); + }); + + it("takes the Zip64 entry count when only the 16-bit count saturates", () => { + const body = new Bytes().raw(Array.from({ length: 40 }, () => 0)); + const cdOffset = body.b.length; + const cd = new Bytes().raw([0xca, 0xfe, 0xca, 0xfe]); + const recordOffset = cdOffset + cd.b.length; + const record = new Bytes() + .u32(0x06064b50) + .u64(44) + .u16(45) + .u16(45) + .u32(0) + .u32(0) + .u64(70000) + .u64(70000) + .u64(cd.b.length) + .u64(cdOffset); + const locator = new Bytes().u32(0x07064b50).u32(0).u64(recordOffset).u32(1); + // Real 32-bit offset and size; only the entry count is saturated. + const eocd = new Bytes() + .u32(0x06054b50) + .u16(0) + .u16(0) + .u16(0xffff) + .u16(0xffff) + .u32(cd.b.length) + .u32(cdOffset) + .u16(0); + const tail = parseZipTail( + Uint8Array.from([ + ...body.b, + ...cd.b, + ...record.b, + ...locator.b, + ...eocd.b, + ]), + ); + expect(tail.ok).toBe(true); + if (!tail.ok) return; + expect(tail.value.totalEntries).toBe(70000); + // apksig reads only the classic count. + expect(tail.value.classicEntryCount).toBe(0xffff); + }); + + it("keeps exactly 65,535 entries when no Zip64 record exists", () => { + const cd = new Bytes().raw([0xca, 0xfe, 0xca, 0xfe]); + const eocd = new Bytes() + .u32(0x06054b50) + .u16(0) + .u16(0) + .u16(0xffff) + .u16(0xffff) + .u32(cd.b.length) + .u32(0) + .u16(0); + const tail = parseZipTail(Uint8Array.from([...cd.b, ...eocd.b])); + expect(tail.ok).toBe(true); + if (!tail.ok) return; + expect(tail.value.totalEntries).toBe(0xffff); + }); +}); + +describe("EOCD that cannot describe the archive", () => { + it("refuses a fake empty EOCD planted in the real EOCD's comment", () => { + const real = apkWithBlock([ + { id: APK_BLOCK_ID.signatureV2, value: [1, 2, 3, 4] }, + ]); + real.set([0x50, 0x4b, 0x03, 0x04], 0); // the archive holds a local entry + // The real EOCD's comment (22 bytes) is itself an EOCD declaring nothing. + real[real.length - 2] = 22; + const fake = new Bytes() + .u32(0x06054b50) + .u16(0) + .u16(0) + .u16(0) + .u16(0) + .u32(0) + .u32(0) + .u16(0); + const bytes = Uint8Array.from([...real, ...fake.b]); + const tail = parseZipTail(bytes); + expect(tail.ok).toBe(false); + if (tail.ok) return; + expect(tail.error.kind).toBe("malformed-zip"); + }); + + it("refuses an archive whose comment holds a second, non-empty EOCD", () => { + const real = apkWithBlock([ + { id: APK_BLOCK_ID.signatureV2, value: [1, 2, 3, 4] }, + ]); + // A fake one-entry EOCD in the comment, pointing at a fake directory. + const fake = new Bytes() + .u32(0x06054b50) + .u16(0) + .u16(0) + .u16(1) + .u16(1) + .u32(4) + .u32(0) + .u16(0); + real[real.length - 2] = fake.b.length; + const tail = parseZipTail(Uint8Array.from([...real, ...fake.b])); + expect(tail.ok).toBe(false); + if (tail.ok) return; + expect(tail.error.kind).toBe("malformed-zip"); + }); +}); diff --git a/src/domain/apkSignature/apkSigningBlock.ts b/src/domain/apkSignature/apkSigningBlock.ts new file mode 100644 index 0000000000..58b6706955 --- /dev/null +++ b/src/domain/apkSignature/apkSigningBlock.ts @@ -0,0 +1,314 @@ +/** + * Locate and frame the APK Signing Block (APK Signature Scheme v2+). The block + * sits between the last ZIP entry and the central directory: + * [ zip entries ][ APK Signing Block ][ central directory ][ EOCD ] + * and is framed as: + * uint64 size | id-value pairs | uint64 size | "APK Sig Block 42" + * where each pair is `uint64 length | uint32 id | value[length-4]`. + * + * Dependency-free and bounded: the end-of-central-directory record is found by a + * bounded backward scan, every size/offset is checked against the buffer before + * use, and a malformed or absent block yields a tagged {@link Result}, never a + * throw. Absence of the block is `ok(null)` (the APK is v1-only or unsigned), + * not an error. All integers are little-endian (ZIP). + */ +import { type Result, err, ok } from "../result.js"; + +const EOCD_SIGNATURE = 0x06054b50; +const ZIP64_EOCD_LOCATOR_SIGNATURE = 0x07064b50; +const ZIP64_EOCD_SIGNATURE = 0x06064b50; +const EOCD_MIN_SIZE = 22; +const MAX_COMMENT = 0xffff; +const ZIP64_SENTINEL = 0xffffffff; +/** The 16-bit EOCD entry count saturates here when Zip64 carries the real count. */ +const ZIP64_ENTRIES_SENTINEL = 0xffff; +/** "APK Sig Block 42" — the 16-byte block magic (JAR/APK signing block spec). */ +const BLOCK_MAGIC = "APK Sig Block 42"; +/** A single pair is at least 12 bytes (uint64 length + uint32 id); cap the count. */ +const MAX_PAIRS = 4096; + +/** Well-known APK Signing Block pair IDs. */ +export const APK_BLOCK_ID = { + signatureV2: 0x7109871a, + signatureV3: 0xf05368c0, + signatureV31: 0x1b93ad61, + sourceStampV1: 0x2b09189e, + sourceStampV2: 0x6dff800d, +} as const; + +export type ApkSigningBlockError = + | { readonly kind: "not-a-zip"; readonly detail: string } + | { readonly kind: "zip64-unsupported"; readonly detail: string } + | { + /** A ZIP whose end-of-central-directory is damaged or inconsistent. */ + readonly kind: "malformed-zip"; + readonly offset: number; + readonly detail: string; + } + | { + readonly kind: "malformed-block"; + readonly offset: number; + readonly detail: string; + } + | { + readonly kind: "budget-exceeded"; + readonly resource: "signing-block-pairs"; + readonly limit: number; + }; + +export interface ZipTail { + readonly eocdOffset: number; + readonly centralDirectoryOffset: number; + readonly centralDirectorySize: number; + readonly totalEntries: number; + /** + * The classic EOCD record's 16-bit entry count. apksig takes the record + * count from this field alone (it ignores a Zip64 record), so it reads only + * this many records when Zip64 declares more. + */ + readonly classicEntryCount: number; +} + +export interface ApkSigningBlockPair { + readonly id: number; + /** View over the block buffer; not copied. */ + readonly value: Uint8Array; +} + +export interface ApkSigningBlock { + readonly blockStart: number; + readonly pairs: readonly ApkSigningBlockPair[]; +} + +const view = (bytes: Uint8Array): DataView => + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + +/** Read an unsigned 64-bit little-endian value as a Number, or null if unsafe/oob. */ +const u64 = (dv: DataView, at: number): number | null => { + if (at < 0 || at + 8 > dv.byteLength) return null; + const value = dv.getBigUint64(at, true); + return value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : null; +}; + +/** The archive starts with a ZIP local file header (`PK\x03\x04`). */ +const hasLocalFileHeader = (bytes: Uint8Array): boolean => + bytes.length >= 4 && + bytes[0] === 0x50 && + bytes[1] === 0x4b && + bytes[2] === 0x03 && + bytes[3] === 0x04; + +/** + * Every EOCD candidate in the permitted comment window whose comment ends + * exactly at the end of the archive, latest first. + */ +const eocdCandidates = (bytes: Uint8Array): number[] => { + const dv = view(bytes); + const found: number[] = []; + const earliest = Math.max(0, bytes.length - EOCD_MIN_SIZE - MAX_COMMENT); + for (let at = bytes.length - EOCD_MIN_SIZE; at >= earliest; at -= 1) { + if (dv.getUint32(at, true) !== EOCD_SIGNATURE) continue; + const commentLength = dv.getUint16(at + 20, true); + if (at + EOCD_MIN_SIZE + commentLength === bytes.length) found.push(at); + } + return found; +}; + +/** Resolve the central-directory offset, following a Zip64 EOCD when present. */ +const centralDirectoryOffset = ( + bytes: Uint8Array, + eocdOffset: number, +): Result< + { offset: number; size: number; entries: number; end: number }, + ApkSigningBlockError +> => { + const dv = view(bytes); + const entries = dv.getUint16(eocdOffset + 10, true); + const size = dv.getUint32(eocdOffset + 12, true); + const offset = dv.getUint32(eocdOffset + 16, true); + const saturated = + offset === ZIP64_SENTINEL || + size === ZIP64_SENTINEL || + entries === ZIP64_ENTRIES_SENTINEL; + if (!saturated) return ok({ offset, size, entries, end: eocdOffset }); + // Zip64: a locator precedes the EOCD, pointing at the Zip64 EOCD record. + const locator = eocdOffset - 20; + if ( + locator < 0 || + dv.getUint32(locator, true) !== ZIP64_EOCD_LOCATOR_SIGNATURE + ) { + // 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 err({ + kind: "zip64-unsupported", + detail: "missing Zip64 EOCD locator", + }); + } + const recordOffset = u64(dv, locator + 8); + if ( + recordOffset === null || + recordOffset + 56 > locator || + dv.getUint32(recordOffset, true) !== ZIP64_EOCD_SIGNATURE + ) + return err({ + kind: "zip64-unsupported", + detail: "malformed Zip64 EOCD record", + }); + const z64Offset = u64(dv, recordOffset + 48); + const z64Size = u64(dv, recordOffset + 40); + // The Zip64 record's total entry count; the 16-bit EOCD field saturates. + const z64Entries = u64(dv, recordOffset + 32); + if (z64Offset === null || z64Size === null || z64Entries === null) + return err({ + kind: "zip64-unsupported", + detail: "Zip64 sizes exceed safe range", + }); + return ok({ + offset: z64Offset, + size: z64Size, + entries: z64Entries, + end: recordOffset, + }); +}; + +/** Parse the ZIP end-of-central-directory to find the central directory. */ +export const parseZipTail = ( + bytes: Uint8Array, +): Result => { + if (bytes.length < EOCD_MIN_SIZE) + return err({ kind: "not-a-zip", detail: "shorter than a ZIP EOCD record" }); + const candidates = eocdCandidates(bytes); + // Two records that each end the archive exactly (one inside the other's + // comment) leave the archive's directory undetermined: whichever a reader + // picks, the other may be attacker-placed. apksigner and Android take the + // one nearest the end; that choice can report a signed APK as unsigned, so + // REA deliberately refuses instead (stricter than apksigner: a crafted APK + // that apksigner verifies may be refused here, never misreported). + if (candidates.length > 1) + return err({ + kind: "malformed-zip", + offset: candidates[0]!, + detail: `${String(candidates.length)} end-of-central-directory records each end the archive (one inside another's comment); the central directory is ambiguous`, + }); + const eocdOffset = candidates[0] ?? null; + if (eocdOffset === null) + // A local-file header without an EOCD is a damaged/truncated archive, not + // a different file type. + return hasLocalFileHeader(bytes) + ? err({ + kind: "malformed-zip", + offset: bytes.length, + detail: + "ZIP local headers present but no end-of-central-directory (truncated or damaged archive)", + }) + : err({ + kind: "not-a-zip", + detail: "no end-of-central-directory record", + }); + const cd = centralDirectoryOffset(bytes, eocdOffset); + if (!cd.ok) return cd; + if ( + cd.value.offset > cd.value.end || + cd.value.size > cd.value.end - cd.value.offset + ) + return err({ + kind: "malformed-zip", + offset: eocdOffset, + detail: + "central-directory extent overlaps or extends past the ZIP end records", + }); + // An EOCD declaring no entries cannot describe an archive that holds a + // local file entry (e.g. a fake EOCD planted in the real one's comment); + // reading on would report a signed APK as confidently unsigned. + if ( + (cd.value.entries === 0 || cd.value.size === 0) && + hasLocalFileHeader(bytes) + ) + return err({ + kind: "malformed-zip", + offset: eocdOffset, + detail: + "end-of-central-directory declares no entries, but the archive holds local file entries", + }); + return ok({ + eocdOffset, + centralDirectoryOffset: cd.value.offset, + centralDirectorySize: cd.value.size, + totalEntries: cd.value.entries, + classicEntryCount: view(bytes).getUint16(eocdOffset + 10, true), + }); +}; + +const hasBlockMagic = (bytes: Uint8Array, cdOffset: number): boolean => { + if (cdOffset < 16) return false; + let magic = ""; + for (let i = 0; i < 16; i += 1) + magic += String.fromCharCode(bytes[cdOffset - 16 + i]!); + return magic === BLOCK_MAGIC; +}; + +/** + * Locate and frame the APK Signing Block immediately before the central + * directory. Returns `ok(null)` when no block magic is present (v1-only or + * unsigned), or the framed id-value pairs otherwise. + */ +export const locateApkSigningBlock = ( + bytes: Uint8Array, + tail: ZipTail, +): Result => { + const cdOffset = tail.centralDirectoryOffset; + if (!hasBlockMagic(bytes, cdOffset)) return ok(null); + const dv = view(bytes); + const trailingSize = u64(dv, cdOffset - 24); + // The footer alone (trailing size + magic) is 24 bytes; a smaller declared + // size would alias the leading and trailing fields (apksig rejects it too). + if (trailingSize === null || trailingSize < 24) + return err({ + kind: "malformed-block", + offset: cdOffset - 24, + detail: "bad trailing size", + }); + const blockStart = cdOffset - trailingSize - 8; + if (blockStart < 0) + return err({ + kind: "malformed-block", + offset: cdOffset, + detail: "block extends before file start", + }); + const leadingSize = u64(dv, blockStart); + if (leadingSize === null || leadingSize !== trailingSize) + return err({ + kind: "malformed-block", + offset: blockStart, + detail: "leading/trailing size mismatch", + }); + + const pairs: ApkSigningBlockPair[] = []; + let cursor = blockStart + 8; + const pairsEnd = cdOffset - 24; // stop before trailing size + magic + while (cursor < pairsEnd) { + const length = u64(dv, cursor); + if (length === null || length < 4 || cursor + 8 + length > pairsEnd + 0) + return err({ + kind: "malformed-block", + offset: cursor, + detail: "pair length out of range", + }); + if (pairs.length >= MAX_PAIRS) + return err({ + kind: "budget-exceeded", + resource: "signing-block-pairs", + limit: MAX_PAIRS, + }); + const id = dv.getUint32(cursor + 8, true); + const valueStart = cursor + 12; + pairs.push({ + id, + value: bytes.subarray(valueStart, valueStart + (length - 4)), + }); + cursor += 8 + length; + } + return ok({ blockStart, pairs }); +}; diff --git a/src/domain/apkSignature/apkV1Signature.test.ts b/src/domain/apkSignature/apkV1Signature.test.ts new file mode 100644 index 0000000000..e8518230f9 --- /dev/null +++ b/src/domain/apkSignature/apkV1Signature.test.ts @@ -0,0 +1,516 @@ +import { describe, expect, it } from "vitest"; + +import { + centralDeclaresEncryption, + centralDirectoryV1SignatureEntries, + detectV1Signature, + localEntryData, + MAX_DUPLICATE_NAME_SAMPLE, + MAX_V1_SIGNATURE_ENTRIES, + type ApkV1Entry, +} from "./apkV1Signature.js"; + +/** A central-directory entry with no stored data. */ +const entry = (name: string): ApkV1Entry => ({ + name, + rawName: new TextEncoder().encode(name), + flags: 0, + method: 0, + crc32: 0, + compressedSize: 0, + uncompressedSize: 0, + localHeaderOffset: 0, +}); +const entries = (names: readonly string[]): ApkV1Entry[] => names.map(entry); + +/** A central-directory file header (46 fixed bytes) for one entry name. */ +const cdRecord = (name: string | readonly number[]): number[] => { + const nameBytes = + typeof name === "string" ? [...name].map((c) => c.charCodeAt(0)) : name; + const buf = new Uint8Array(46); + const view = new DataView(buf.buffer); + view.setUint32(0, 0x02014b50, true); // signature + view.setUint16(28, nameBytes.length, true); // file name length + return [...buf, ...nameBytes]; +}; + +const centralDirectory = ( + names: readonly (string | readonly number[])[], +): Uint8Array => Uint8Array.from(names.flatMap((name) => cdRecord(name))); + +/** Duplicate-name samples for ASCII names. */ +const named = (names: readonly string[]) => + names.map((name) => ({ name, storedBytes: name.length })); + +const NO_DUPLICATES = { count: 0, sample: [] }; + +it("rejects out-of-file extents instead of clamping the declared directory", () => { + const bytes = centralDirectory(["classes.dex"]); + expect( + centralDirectoryV1SignatureEntries(bytes, 0, bytes.length + 1, 1), + ).toMatchObject({ + ok: false, + error: { kind: "malformed-central-directory" }, + }); +}); + +describe("APK v1 (JAR) signature detection", () => { + const names = [ + "AndroidManifest.xml", + "classes.dex", + "META-INF/MANIFEST.MF", + "META-INF/CERT.SF", + "META-INF/CERT.RSA", + ]; + + it("walks every record and keeps the META-INF signing names at any depth", () => { + const all = [ + ...names, + "META-INF/services/x.Y", + "META-INF/sub/S.SF", + "META-INF/sub/S.EC", + "lib/META-INF/z.SF", + ]; + const bytes = centralDirectory(all); + const result = centralDirectoryV1SignatureEntries( + bytes, + 0, + bytes.length, + all.length, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.entries.map((e) => e.name)).toEqual([ + "META-INF/MANIFEST.MF", + "META-INF/CERT.SF", + "META-INF/CERT.RSA", + "META-INF/sub/S.SF", + "META-INF/sub/S.EC", + ]); + }); + + it("pairs signature files and blocks as apksig does", () => { + expect( + detectV1Signature( + entries(["META-INF/sub/CERT.SF", "META-INF/sub/CERT.DSA"]), + ), + ).toEqual({ + present: true, + hasManifest: false, + signatureFiles: ["META-INF/sub/CERT.SF"], + blockFiles: ["META-INF/sub/CERT.DSA"], + signers: [ + { + signatureFile: "META-INF/sub/CERT.SF", + block: entry("META-INF/sub/CERT.DSA"), + duplicate: null, + }, + ], + }); + // A block without its own .SF partner is not a v1 signer. + expect( + detectV1Signature( + entries([ + "META-INF/MANIFEST.MF", + "META-INF/RSA1.SF", + "META-INF/OTHER.RSA", + ]), + ).present, + ).toBe(false); + // Suffixes are case-sensitive. + expect( + detectV1Signature(entries(["META-INF/CERT.sf", "META-INF/CERT.rsa"])) + .present, + ).toBe(false); + }); + + it("refuses more META-INF signing entries than a signed archive needs", () => { + const many = Array.from( + { length: MAX_V1_SIGNATURE_ENTRIES + 1 }, + (_, i) => `META-INF/F${String(i)}.SF`, + ); + const bytes = centralDirectory(many); + const result = centralDirectoryV1SignatureEntries( + bytes, + 0, + bytes.length, + many.length, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toEqual({ + kind: "budget-exceeded", + resource: "v1-signature-entries", + limit: MAX_V1_SIGNATURE_ENTRIES, + }); + }); + + it("detects a v1-signed APK from its META-INF files", () => { + const bytes = centralDirectory(names); + const result = centralDirectoryV1SignatureEntries( + bytes, + 0, + bytes.length, + names.length, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + const v1 = detectV1Signature(result.value.entries); + expect(result.value.duplicates).toEqual({ + ...NO_DUPLICATES, + records: names.length, + apksigRecords: names.length, + unread: NO_DUPLICATES, + }); + expect(v1).toMatchObject({ + present: true, + hasManifest: true, + signatureFiles: ["META-INF/CERT.SF"], + blockFiles: ["META-INF/CERT.RSA"], + signers: [{ signatureFile: "META-INF/CERT.SF" }], + }); + }); + + it("refuses a directory that ends before its declared record count", () => { + const bytes = centralDirectory(names); + const result = centralDirectoryV1SignatureEntries( + bytes, + 0, + bytes.length - 3, + names.length, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.kind).toBe("malformed-central-directory"); + }); + + it("refuses an under-declared record count instead of hiding entries", () => { + const bytes = centralDirectory(names); + const result = centralDirectoryV1SignatureEntries( + bytes, + 0, + bytes.length, + 1, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toMatchObject({ + kind: "malformed-central-directory", + detail: expect.stringContaining("remain after 1 records") as string, + }); + }); + + it("reports absent v1 signing when no META-INF signature files exist", () => { + const v1 = detectV1Signature( + entries(["classes.dex", "res/layout/main.xml"]), + ); + expect(v1.present).toBe(false); + expect(v1.signatureFiles).toEqual([]); + }); +}); + +describe("APK v1 duplicate signing names", () => { + const scan = ( + all: readonly (string | readonly number[])[], + apksigRecords = all.length, + ) => { + const bytes = centralDirectory(all); + const result = centralDirectoryV1SignatureEntries( + bytes, + 0, + bytes.length, + all.length, + apksigRecords, + ); + if (!result.ok) throw new Error(JSON.stringify(result.error)); + return result.value; + }; + + it("finds duplicate names among all records, as apksig rejects any of them", () => { + expect( + scan([ + "classes.dex", + "res/a.xml", + "META-INF/CERT.SF", + "res/a.xml", + "classes.dex", + "classes.dex", + "Classes.dex", + "res/a.xm", + ]).duplicates, + ).toMatchObject({ + count: 2, + sample: named(["classes.dex", "res/a.xml"]), + unread: NO_DUPLICATES, + }); + }); + + it("decodes only the first few duplicate names, in first-occurrence order", () => { + const names = Array.from( + { length: MAX_DUPLICATE_NAME_SAMPLE + 3 }, + (_, i) => `assets/f${String(i)}`, + ); + const { duplicates } = scan([...names, ...[...names].reverse()]); + expect(duplicates).toMatchObject({ + count: names.length, + sample: named(names.slice(0, MAX_DUPLICATE_NAME_SAMPLE)), + }); + }); + + it("compares the names whose hashes collide instead of trusting the hash", () => { + // These two distinct names share their 29-bit FNV-1a sort hash. + const [a, b] = ["res/c52831", "res/c112929"]; + expect(scan([a, b]).duplicates).toMatchObject(NO_DUPLICATES); + expect(scan([b, a, "x", b, a]).duplicates).toMatchObject({ + count: 2, + sample: named([b, a]), + }); + }); + + it("compares malformed names as apksig's Java decoding does", () => { + const dup = [0x64, 0x75, 0x70]; + // dup\xff, dup\xfe and dup + U+FFFD all decode to "dup\uFFFD"; an encoded + // surrogate is one replacement for Java (three for WHATWG's TextDecoder). + const { duplicates } = scan([ + [...dup, 0xff], + "x", + [...dup, 0xfe], + [...dup, 0xef, 0xbf, 0xbd], + [0x78, 0xed, 0xa0, 0x80], + [0x78, 0xff], + [0x78, 0xff, 0xff], + ]); + expect(duplicates).toMatchObject({ + count: 2, + sample: [ + { name: "dup\uFFFD", storedBytes: 4 }, + { name: "x\uFFFD", storedBytes: 4 }, + ], + }); + }); + + it("counts for apksig only duplicates within the records apksig reads", () => { + // apksig reads the classic 16-bit count (3 here) of a Zip64 directory. + expect(scan(["a", "b", "a", "c", "b", "c", "a"], 3).duplicates).toEqual({ + count: 1, + sample: named(["a"]), + records: 7, + apksigRecords: 3, + unread: { count: 2, sample: named(["b", "c"]) }, + }); + }); + + it("marks every signer when the manifest is duplicated", () => { + const v1 = detectV1Signature( + entries([ + "META-INF/MANIFEST.MF", + "META-INF/A.SF", + "META-INF/A.RSA", + "META-INF/B.SF", + "META-INF/B.EC", + "META-INF/MANIFEST.MF", + ]), + ); + expect(v1.signers.map(({ duplicate }) => duplicate)).toEqual([ + "META-INF/MANIFEST.MF", + "META-INF/MANIFEST.MF", + ]); + }); + + it("flags duplicate signing names and keeps one signer per block name", () => { + const v1 = detectV1Signature( + entries([ + "META-INF/CERT.SF", + "META-INF/CERT.RSA", + "META-INF/CERT.RSA", + "META-INF/OK.SF", + "META-INF/OK.EC", + "META-INF/TWO.SF", + "META-INF/TWO.SF", + "META-INF/TWO.DSA", + "META-INF/MANIFEST.MF", + "META-INF/MANIFEST.MF", + ]), + ); + expect( + v1.signers.map(({ block, duplicate }) => [block.name, duplicate]), + ).toEqual([ + ["META-INF/CERT.RSA", "META-INF/CERT.RSA"], + // The duplicated manifest affects every signer. + ["META-INF/OK.EC", "META-INF/MANIFEST.MF"], + ["META-INF/TWO.DSA", "META-INF/TWO.SF"], + ]); + // The observed names stay as the central directory lists them. + expect(v1.blockFiles).toEqual([ + "META-INF/CERT.RSA", + "META-INF/CERT.RSA", + "META-INF/OK.EC", + "META-INF/TWO.DSA", + ]); + }); +}); + +/** A local file header for `block`, then `extra` and the stored data. */ +const localRecord = ( + block: ApkV1Entry, + data: readonly number[], + override: { + readonly flags?: number; + readonly crc32?: number; + readonly compressedSize?: number; + readonly uncompressedSize?: number; + readonly name?: string | Uint8Array; + } = {}, +): Uint8Array => { + const name = + override.name === undefined + ? block.rawName + : typeof override.name === "string" + ? new TextEncoder().encode(override.name) + : override.name; + const local = new Uint8Array(30 + name.length + 3 + data.length); + const view = new DataView(local.buffer); + view.setUint32(0, 0x04034b50, true); + view.setUint16(6, override.flags ?? block.flags, true); + view.setUint32(14, override.crc32 ?? block.crc32, true); + view.setUint32(18, override.compressedSize ?? block.compressedSize, true); + view.setUint32(22, override.uncompressedSize ?? block.uncompressedSize, true); + view.setUint16(26, name.length, true); + view.setUint16(28, 3, true); + local.set(name, 30); + local.set(data, 30 + name.length + 3); + return local; +}; +const block: ApkV1Entry = { + ...entry("META-INF/A.RSA"), + crc32: 0x01020304, + compressedSize: 4, + uncompressedSize: 4, +}; +const data = [9, 8, 7, 6]; +const dataStart = 30 + block.rawName.length + 3; + +describe("APK v1 signature block locations", () => { + it("keeps each record's data location for its signature block", () => { + const record = cdRecord("META-INF/CERT.RSA"); + const view = new DataView(Uint8Array.from(record).buffer); + view.setUint16(8, 0x0808, true); + view.setUint16(10, 8, true); + view.setUint32(16, 0xdeadbeef, true); + view.setUint32(20, 70, true); + view.setUint32(24, 90, true); + view.setUint32(42, 1234, true); + const bytes = new Uint8Array(view.buffer); + const result = centralDirectoryV1SignatureEntries( + bytes, + 0, + bytes.length, + 1, + ); + expect(result.ok && result.value.entries).toEqual([ + { + name: "META-INF/CERT.RSA", + rawName: new TextEncoder().encode("META-INF/CERT.RSA"), + flags: 0x0808, + method: 8, + crc32: 0xdeadbeef, + compressedSize: 70, + uncompressedSize: 90, + localHeaderOffset: 1234, + }, + ]); + }); + + it("locates stored data through the local header, within the entry region", () => { + const local = localRecord(block, data); + const found = localEntryData(local, block, local.length); + expect(found.ok && [...found.value.data]).toEqual(data); + expect(found.ok && found.value.declaresEncryption).toBe(false); + expect(localEntryData(local, block, local.length - 1)).toEqual({ + ok: false, + error: { + detail: `entry data at byte ${String(dataStart)} runs past the entries`, + }, + }); + expect( + localEntryData(local, { ...block, localHeaderOffset: 1 }, local.length), + ).toMatchObject({ ok: false }); + }); + + it("names Zip64 sizes as unread before any other check", () => { + const local = localRecord(block, data); + for (const field of ["compressedSize", "uncompressedSize"] as const) + expect( + localEntryData(local, { ...block, [field]: 0xffffffff }, local.length), + ).toEqual({ + ok: false, + error: { detail: "Zip64 entry sizes are not read" }, + }); + }); + + it("leaves the data unknown when the local header disagrees with the central directory", () => { + const refusal = (local: Uint8Array, entry: ApkV1Entry = block) => { + const found = localEntryData(local, entry, local.length); + return found.ok ? null : found.error.detail; + }; + expect(refusal(localRecord(block, data, { compressedSize: 5 }))).toBe( + "local header compressed size 5 differs from the central directory's 4", + ); + expect(refusal(localRecord(block, data, { uncompressedSize: 3 }))).toBe( + "local header uncompressed size 3 differs from the central directory's 4", + ); + expect(refusal(localRecord(block, data, { crc32: 7 }))).toBe( + "local header CRC-32 7 differs from the central directory's 16909060", + ); + expect(refusal(localRecord(block, data, { name: "META-INF/A.XYZ" }))).toBe( + "local header name differs from the central directory's", + ); + expect(refusal(localRecord(block, data, { name: "META-INF/A.RS" }))).toBe( + "local header name differs from the central directory's", + ); + // Names that decode alike: apksig accepts a local name no longer in bytes + // than the directory's and refuses a longer one before decoding. + const malformed = (tail: number[]) => + Uint8Array.from([...new TextEncoder().encode("META-INF/K"), ...tail]); + const short = { ...block, rawName: malformed([0xed, 0xa0, 0x80]) }; + expect( + refusal(localRecord(short, data, { name: malformed([0xff]) }), short), + ).toBeNull(); + const long = { ...block, rawName: malformed([0xff]) }; + expect( + refusal( + localRecord(long, data, { name: malformed([0xed, 0xa0, 0x80]) }), + long, + ), + ).toBe("local header name differs from the central directory's"); + expect(refusal(localRecord(block, data, { flags: 0x8 }))).toBe( + "local header and central directory disagree on the data-descriptor flag", + ); + // With a data descriptor on both sides the local sizes are not declared. + const described = { ...block, flags: 0x8 }; + expect( + refusal( + localRecord(described, data, { + crc32: 0, + compressedSize: 0, + uncompressedSize: 0, + }), + described, + ), + ).toBeNull(); + }); + + it("reads an entry flagged as encrypted, as apksig ignores the flag", () => { + const flagged = { ...block, flags: 0x1 }; + const both = localRecord(flagged, data); + const read = localEntryData(both, flagged, both.length); + expect(read.ok && [...read.value.data]).toEqual(data); + expect(read.ok && read.value.declaresEncryption).toBe(true); + expect(centralDeclaresEncryption(flagged)).toBe(true); + // Either header's flag is reported. + const local = localRecord(block, data, { flags: 0x1 }); + const localOnly = localEntryData(local, block, local.length); + expect(localOnly.ok && localOnly.value.declaresEncryption).toBe(true); + expect(centralDeclaresEncryption(block)).toBe(false); + }); +}); diff --git a/src/domain/apkSignature/apkV1Signature.ts b/src/domain/apkSignature/apkV1Signature.ts new file mode 100644 index 0000000000..ff1a8c292f --- /dev/null +++ b/src/domain/apkSignature/apkV1Signature.ts @@ -0,0 +1,507 @@ +/** + * Detect v1 (JAR) signing from the ZIP central directory as apksig's + * V1SchemeVerifier selects it: among entries whose name starts with + * `META-INF/` (at any depth), `META-INF/MANIFEST.MF` is the manifest, names + * ending in `.SF` are signature files and names ending in `.RSA`/`.DSA`/`.EC` + * are signature blocks (all case-sensitive). A signer exists only where a + * block's `.SF` partner (`name.substring(0, lastIndexOf('.')) + ".SF"`) is + * present. The PKCS#7 blocks are not parsed, so no v1 signature is verified. + * + * Dependency-free and bounded: central-directory records are walked within the + * declared directory extent and entry count; a record running past it is a + * tagged error rather than a silently shorter list. Only the signing-relevant + * `META-INF/` names are decoded (as Java decodes them) and retained, capped at + * a format-justified count. Duplicate names are found among all records, as + * apksig rejects a v1 signature for any of them, with 15 bytes of typed-array + * state per record (up to ~11M records in a 512 MiB Zip64 file) and only a few + * names decoded. + */ +import { type Result, err, ok } from "../result.js"; +import { + canonicalNameHash, + compareCanonicalNames, + decodeZipName, + isCanonicalZipName, + sameZipName, + type StoredZipName, +} from "./zipEntryName.js"; + +const CD_SIGNATURE = 0x02014b50; +const CD_FIXED_SIZE = 46; + +export type ApkCentralDirectoryError = + | { + readonly kind: "malformed-central-directory"; + readonly offset: number; + readonly detail: string; + } + | { + readonly kind: "budget-exceeded"; + readonly resource: "v1-signature-entries"; + readonly limit: number; + }; + +/** + * Signing-relevant `META-INF/` entries retained. A signed APK holds a manifest + * plus one `.SF` and one block per v1 signer; thousands is already pathological. + */ +export const MAX_V1_SIGNATURE_ENTRIES = 4096; + +/** + * v1 signers (signature block + `.SF` pairs) decoded. apksig accepts at most + * 10; more only multiplies work and output, so the report is refused. + */ +export const MAX_V1_SIGNERS = 32; + +/** Duplicated central-directory names decoded for the report; the rest are counted. */ +export const MAX_DUPLICATE_NAME_SAMPLE = 5; + +const META_INF = "META-INF/"; +const MANIFEST = "META-INF/MANIFEST.MF"; +const META_INF_BYTES = Uint8Array.from( + [...META_INF].map((c) => c.charCodeAt(0)), +); + +/** True when the raw name starts with `META-INF/` (apksig's selection). */ +const startsWithMetaInf = ( + bytes: Uint8Array, + start: number, + length: number, +): boolean => { + if (length < META_INF_BYTES.length) return false; + for (let i = 0; i < META_INF_BYTES.length; i += 1) + if (bytes[start + i] !== META_INF_BYTES[i]) return false; + return true; +}; + +const BLOCK_SUFFIXES = [".RSA", ".DSA", ".EC"] as const; +const isSignatureFile = (name: string): boolean => name.endsWith(".SF"); +const isSignatureBlock = (name: string): boolean => + BLOCK_SUFFIXES.some((suffix) => name.endsWith(suffix)); + +/** The `.SF` file apksig pairs with a signature block. */ +const signatureFileFor = (block: string): string => + `${block.slice(0, block.lastIndexOf("."))}.SF`; + +/** The one owner of which `META-INF/` names take part in v1 signing. */ +const isV1SignatureName = (name: string): boolean => + name === MANIFEST || isSignatureFile(name) || isSignatureBlock(name); + +/** A signing-relevant central-directory entry and where its data is stored. */ +export interface ApkV1Entry { + readonly name: string; + /** The name exactly as the central directory stores it (a view, not a copy). */ + readonly rawName: Uint8Array; + /** General-purpose bit flags (bit 0 encrypted, bit 3 data descriptor). */ + readonly flags: number; + /** ZIP compression method (0 stored, 8 deflate). */ + readonly method: number; + readonly crc32: number; + readonly compressedSize: number; + readonly uncompressedSize: number; + readonly localHeaderOffset: number; +} + +/** A v1 signer as apksig pairs it: one signature block and its `.SF` partner. */ +export interface ApkV1SignerEntry { + readonly signatureFile: string; + /** The first central-directory entry with the block's name. */ + readonly block: ApkV1Entry; + /** + * The block, `.SF` or `META-INF/MANIFEST.MF` name the central directory + * holds more than once, which apksig rejects ("Duplicate entry"); null when + * all three are unique. + */ + readonly duplicate: string | null; +} + +export interface ApkV1Signature { + /** At least one signature block has its matching `.SF` signature file. */ + readonly present: boolean; + readonly hasManifest: boolean; + /** `META-INF/**.SF` signature files. */ + readonly signatureFiles: readonly string[]; + /** `META-INF/**.(RSA|DSA|EC)` PKCS#7 signature blocks. */ + readonly blockFiles: readonly string[]; + /** Blocks paired with their `.SF` file, one per block name, in central-directory order. */ + readonly signers: readonly ApkV1SignerEntry[]; +} + +/** A duplicated name as apksig decodes it, with its stored length. */ +export interface ApkDuplicateName { + readonly name: string; + /** The name's length as the central directory stores it. */ + readonly storedBytes: number; +} + +/** Distinct duplicated names: how many, and the first few. */ +export interface ApkDuplicateNameSample { + readonly count: number; + /** The first few, in central-directory order of their first occurrence. */ + readonly sample: readonly ApkDuplicateName[]; +} + +/** + * Names the central directory holds more than once, as apksig sees them: + * `count`/`sample` cover the records apksig reads; `unread` names those + * whose second copy lies only past them (a Zip64 count above the classic + * end-of-central-directory record's 16-bit count, which apksig never sees). + */ +export interface ApkDuplicateNames extends ApkDuplicateNameSample { + /** Records walked: every record the end-of-central-directory declares. */ + readonly records: number; + /** Records apksig reads (the classic 16-bit count, at most `records`). */ + readonly apksigRecords: number; + readonly unread: ApkDuplicateNameSample; +} + +/** The signing-relevant entries plus the duplicate names of the whole directory. */ +export interface ApkCentralDirectoryScan { + readonly entries: readonly ApkV1Entry[]; + readonly duplicates: ApkDuplicateNames; +} + +/** Byte order of two stored well-formed names, compared in place. */ +const compareStoredNames = ( + bytes: Uint8Array, + starts: Uint32Array, + lengths: Uint16Array, + a: number, + b: number, +): number => { + const as = starts[a] ?? 0; + const bs = starts[b] ?? 0; + const al = lengths[a] ?? 0; + const bl = lengths[b] ?? 0; + for (let k = 0; k < Math.min(al, bl); k += 1) { + const d = (bytes[as + k] ?? 0) - (bytes[bs + k] ?? 0); + if (d !== 0) return d; + } + return al - bl; +}; + +/** 2^24 records exceed any central directory in a 512 MiB archive (46 bytes each). */ +const INDEX_BITS = 2 ** 24; + +/** + * Names stored more than once, compared as apksig compares the decoded + * names: by their canonical bytes (see zipEntryName.ts), which are the stored + * bytes of every well-formed name. Each record's name is hashed into the high + * bits of one sortable number with its index in the low bits; only records + * whose hashes collide are compared, so the work is O(n log n) even for + * crafted collisions. A name counts for apksig when its second copy lies + * within the records apksig reads. + */ +const duplicateNames = ( + bytes: Uint8Array, + starts: Uint32Array, + lengths: Uint16Array, + count: number, + apksigRecords: number, +): ApkDuplicateNames => { + const wellFormed = new Uint8Array(count); + const keys = new Float64Array(count); + for (let i = 0; i < count; i += 1) { + const start = starts[i] ?? 0; + const stop = start + (lengths[i] ?? 0); + const canonical = isCanonicalZipName(bytes, start, stop); + wellFormed[i] = canonical ? 1 : 0; + const hash = canonicalNameHash(bytes, start, stop, canonical); + // 29 hash bits and 24 index bits stay within a double's exact integers. + keys[i] = (hash >>> 3) * INDEX_BITS + i; + } + keys.sort(); + const stored = (i: number): StoredZipName => { + const start = starts[i] ?? 0; + return { bytes, start, stop: start + (lengths[i] ?? 0) }; + }; + // Well-formed names are their own canonical bytes: compared in place. + const compare = (a: number, b: number): number => + wellFormed[a] === 1 && wellFormed[b] === 1 + ? compareStoredNames(bytes, starts, lengths, a, b) + : compareCanonicalNames(stored(a), stored(b)); + const seen: number[] = []; + const unread: number[] = []; + for (let run = 0; run < count;) { + const hash = Math.floor((keys[run] ?? 0) / INDEX_BITS); + let stop = run + 1; + while (stop < count && Math.floor((keys[stop] ?? 0) / INDEX_BITS) === hash) + stop += 1; + if (stop - run > 1) { + const group = Array.from( + keys.subarray(run, stop), + (key) => key % INDEX_BITS, + ).sort((a, b) => compare(a, b) || a - b); + // Sorted by name, then record order: the first two of each run of equal + // names are that name's first and second occurrences. + let previousSame = false; + for (let k = 1; k < group.length; k += 1) { + const first = group[k - 1] ?? 0; + const second = group[k] ?? 0; + const same = compare(first, second) === 0; + if (same && !previousSame) + (second < apksigRecords ? seen : unread).push(first); + previousSame = same; + } + } + run = stop; + } + const sampled = (firsts: number[]): ApkDuplicateNameSample => { + firsts.sort((a, b) => a - b); + return { + count: firsts.length, + sample: firsts.slice(0, MAX_DUPLICATE_NAME_SAMPLE).map((i) => { + const start = starts[i] ?? 0; + const storedBytes = lengths[i] ?? 0; + return { + name: decodeZipName(bytes.subarray(start, start + storedBytes)), + storedBytes, + }; + }), + }; + }; + return { + ...sampled(seen), + records: count, + apksigRecords: Math.min(apksigRecords, count), + unread: sampled(unread), + }; +}; + +/** + * Walk every central-directory record within the declared extent and count, + * returning the `META-INF/` names that take part in v1 signing and the names + * held more than once. A record that runs past the declared extent is an + * error, not a silently shorter list: a partial walk would turn "unknown" + * into "absent". `apksigRecords` is how many leading records apksig reads + * (the classic end-of-central-directory count); it defaults to all of them. + */ +export const centralDirectoryV1SignatureEntries = ( + bytes: Uint8Array, + centralDirectoryOffset: number, + centralDirectorySize: number, + totalEntries: number, + apksigRecords: number = totalEntries, +): Result => { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const end = centralDirectoryOffset + centralDirectorySize; + if ( + !Number.isSafeInteger(end) || + centralDirectoryOffset < 0 || + centralDirectorySize < 0 || + end > bytes.length + ) + return err({ + kind: "malformed-central-directory", + offset: centralDirectoryOffset, + detail: "declared central-directory extent is outside the file", + }); + // A record takes at least its fixed bytes, so the walk fails before a + // declared count past this capacity is reached. + const capacity = Math.min( + totalEntries, + Math.max(0, Math.floor((end - centralDirectoryOffset) / CD_FIXED_SIZE)), + ); + const starts = new Uint32Array(capacity); + const lengths = new Uint16Array(capacity); + const entries: ApkV1Entry[] = []; + let at = centralDirectoryOffset; + for (let i = 0; i < totalEntries; i += 1) { + if (at + CD_FIXED_SIZE > end) + return err({ + kind: "malformed-central-directory", + offset: at, + detail: `central directory ends after ${String(i)} of ${String(totalEntries)} records`, + }); + if (dv.getUint32(at, true) !== CD_SIGNATURE) + return err({ + kind: "malformed-central-directory", + offset: at, + detail: "central-directory record signature mismatch", + }); + const nameLength = dv.getUint16(at + 28, true); + const extraLength = dv.getUint16(at + 30, true); + const commentLength = dv.getUint16(at + 32, true); + const nameStart = at + CD_FIXED_SIZE; + if (nameStart + nameLength > end) + return err({ + kind: "malformed-central-directory", + offset: nameStart, + detail: "central-directory file name runs past the directory", + }); + starts[i] = nameStart; + lengths[i] = nameLength; + if (startsWithMetaInf(bytes, nameStart, nameLength)) { + const rawName = bytes.subarray(nameStart, nameStart + nameLength); + const name = decodeZipName(rawName); + if (isV1SignatureName(name)) { + if (entries.length >= MAX_V1_SIGNATURE_ENTRIES) + return err({ + kind: "budget-exceeded", + resource: "v1-signature-entries", + limit: MAX_V1_SIGNATURE_ENTRIES, + }); + entries.push({ + name, + rawName, + flags: dv.getUint16(at + 8, true), + method: dv.getUint16(at + 10, true), + crc32: dv.getUint32(at + 16, true), + compressedSize: dv.getUint32(at + 20, true), + uncompressedSize: dv.getUint32(at + 24, true), + localHeaderOffset: dv.getUint32(at + 42, true), + }); + } + } + at = nameStart + nameLength + extraLength + commentLength; + } + // An under-declared record count would silently hide entries (and so v1 + // signing): the walk must consume exactly the declared directory. + if (at !== end) + return err({ + kind: "malformed-central-directory", + offset: at, + detail: `${String(end - at)} bytes of the declared central directory remain after ${String(totalEntries)} records`, + }); + return ok({ + entries, + duplicates: duplicateNames(bytes, starts, lengths, capacity, apksigRecords), + }); +}; + +/** Classify central-directory entries into the v1 (JAR) signing facts. */ +export const detectV1Signature = ( + entries: readonly ApkV1Entry[], +): ApkV1Signature => { + const selected = entries.filter((entry) => entry.name.startsWith(META_INF)); + // Case-sensitive throughout, as apksig matches JAR signature entries. + const seen = new Set(); + const duplicates = new Set(); + for (const { name } of selected) + if (seen.has(name)) duplicates.add(name); + else seen.add(name); + const signatureFiles = selected + .map((entry) => entry.name) + .filter(isSignatureFile); + const blocks = selected.filter((entry) => isSignatureBlock(entry.name)); + const sfNames = new Set(signatureFiles); + const paired = new Set(); + const signers = blocks.flatMap((block) => { + const signatureFile = signatureFileFor(block.name); + if (!sfNames.has(signatureFile) || paired.has(block.name)) return []; + paired.add(block.name); + // apksig reads every signer's manifest, so a duplicate one affects them all. + const duplicate = + [block.name, signatureFile, MANIFEST].find((name) => + duplicates.has(name), + ) ?? null; + return [{ signatureFile, block, duplicate }]; + }); + return { + present: signers.length > 0, + hasManifest: selected.some((entry) => entry.name === MANIFEST), + signatureFiles, + blockFiles: blocks.map((block) => block.name), + signers, + }; +}; + +const LOCAL_SIGNATURE = 0x04034b50; +const LOCAL_FIXED_SIZE = 30; +const ZIP64_SENTINEL = 0xffffffff; +const FLAG_ENCRYPTED = 0x1; +const FLAG_DATA_DESCRIPTOR = 0x8; + +/** The central directory sets the entry's encryption flag (bit 0). */ +export const centralDeclaresEncryption = (entry: ApkV1Entry): boolean => + (entry.flags & FLAG_ENCRYPTED) !== 0; + +/** Why an entry's stored bytes could not be located. */ +export type ApkLocalEntryError = { readonly detail: string }; + +/** An entry's stored bytes and whether either header sets the encryption flag. */ +export interface ApkLocalEntryData { + readonly data: Uint8Array; + readonly declaresEncryption: boolean; +} + +/** + * Locate an entry's stored (possibly compressed) bytes through its local file + * header. The data must lie within the archive's entry region (before the + * signing block or central directory) and its declared sizes must be read + * directly, so Zip64-sized entries are reported, not guessed. As apksig's + * LocalFileRecord does, the local header must agree with the central + * directory on the name (no longer in bytes, then equal once decoded, as + * apksig compares them), the + * data-descriptor flag and (without a data + * descriptor) the CRC-32 and both sizes: a disagreement leaves the entry's + * bytes unknown, never read from one side. Like LocalFileRecord, the + * encryption flag (bit 0) does not stop the read; it is returned so the caller + * can say so, and bytes that really are encrypted fail to decode. + */ +export const localEntryData = ( + bytes: Uint8Array, + entry: ApkV1Entry, + entriesEnd: number, +): Result => { + if ( + entry.compressedSize === ZIP64_SENTINEL || + entry.uncompressedSize === ZIP64_SENTINEL || + entry.localHeaderOffset === ZIP64_SENTINEL + ) + return err({ detail: "Zip64 entry sizes are not read" }); + const at = entry.localHeaderOffset; + const limit = Math.min(entriesEnd, bytes.length); + if (at + LOCAL_FIXED_SIZE > limit) + return err({ + detail: `local header at byte ${String(at)} is out of range`, + }); + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (dv.getUint32(at, true) !== LOCAL_SIGNATURE) + return err({ + detail: `local header signature mismatch at byte ${String(at)}`, + }); + const flags = dv.getUint16(at + 6, true); + const descriptor = (flags & FLAG_DATA_DESCRIPTOR) !== 0; + if (descriptor !== ((entry.flags & FLAG_DATA_DESCRIPTOR) !== 0)) + return err({ + detail: + "local header and central directory disagree on the data-descriptor flag", + }); + if (!descriptor) + for (const [field, offset, expected] of [ + ["CRC-32", 14, entry.crc32], + ["compressed size", 18, entry.compressedSize], + ["uncompressed size", 22, entry.uncompressedSize], + ] as const) { + const local = dv.getUint32(at + offset, true); + if (local !== expected) + return err({ + detail: `local header ${field} ${String(local)} differs from the central directory's ${String(expected)}`, + }); + } + const nameLength = dv.getUint16(at + 26, true); + const nameStart = at + LOCAL_FIXED_SIZE; + if ( + nameStart + nameLength > limit || + // apksig refuses a local name longer in bytes before decoding either. + nameLength > entry.rawName.length || + !sameZipName( + bytes.subarray(nameStart, nameStart + nameLength), + entry.rawName, + ) + ) + return err({ + detail: "local header name differs from the central directory's", + }); + const start = nameStart + nameLength + dv.getUint16(at + 28, true); + const end = start + entry.compressedSize; + if (end > limit) + return err({ + detail: `entry data at byte ${String(start)} runs past the entries`, + }); + return ok({ + data: bytes.subarray(start, end), + declaresEncryption: ((entry.flags | flags) & FLAG_ENCRYPTED) !== 0, + }); +}; diff --git a/src/domain/apkSignature/apkV1SignatureBlock.test.ts b/src/domain/apkSignature/apkV1SignatureBlock.test.ts new file mode 100644 index 0000000000..dbbbaa87ca --- /dev/null +++ b/src/domain/apkSignature/apkV1SignatureBlock.test.ts @@ -0,0 +1,206 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { + indefinite, + LEAF_CERTIFICATE, + LEAF_SHA256, + OID_ECDSA_SHA256, + OID_SHA256, + OPENSSL_BLOCK_ISSUER_SERIAL, + OPENSSL_BLOCK_KEY_ID, + ROOT_CERTIFICATE, + ROOT_SHA256, + signedDataBlock, + signerInfo, + tlv, +} from "../../../tests/fixtures/apkSignature/v1SignatureBlock.js"; +import { + decodeV1SignatureBlock, + MAX_DER_ELEMENTS, + MAX_V1_BLOCK_CERTIFICATES, + MAX_V1_SIGNER_INFOS, +} from "./apkV1SignatureBlock.js"; + +const sha256 = (bytes: Uint8Array): string => + createHash("sha256").update(bytes).digest("hex"); + +const decoded = (bytes: Uint8Array) => { + const result = decodeV1SignatureBlock(bytes); + if (!result.ok) throw new Error(JSON.stringify(result.error)); + return result.value; +}; + +const errorOf = (bytes: Uint8Array) => { + const result = decodeV1SignatureBlock(bytes); + if (result.ok) throw new Error("expected a decode error"); + return result.error; +}; + +it.each([ + new Uint8Array(), + tlv(0x02, Uint8Array.of(1)), + Uint8Array.of(0x04, 0x02, 1), +])( + "refuses an absent, mistagged or truncated SignerInfo signature", + (signature) => { + expect( + decodeV1SignatureBlock( + signedDataBlock({ + certificates: [LEAF_CERTIFICATE], + signerInfos: [signerInfo({ names: LEAF_CERTIFICATE, signature })], + }), + ), + ).toMatchObject({ ok: false, error: { kind: "malformed-pkcs7" } }); + }, +); + +describe("v1 signature block (PKCS#7 SignedData)", () => { + it("names the leaf an OpenSSL SignerInfo identifies by issuer and serial", () => { + const block = decoded(OPENSSL_BLOCK_ISSUER_SERIAL); + expect(block.certificates.map(sha256)).toEqual([LEAF_SHA256, ROOT_SHA256]); + expect(block.signerInfos).toEqual([ + { + identifiedBy: "issuer-and-serial", + certificateIndex: 0, + digestAlgorithmOid: OID_SHA256, + signatureAlgorithmOid: OID_ECDSA_SHA256, + }, + ]); + }); + + it("names the leaf a SignerInfo identifies by subjectKeyIdentifier", () => { + const block = decoded(OPENSSL_BLOCK_KEY_ID); + expect(block.signerInfos).toEqual([ + { + identifiedBy: "subject-key-identifier", + certificateIndex: 0, + digestAlgorithmOid: OID_SHA256, + signatureAlgorithmOid: OID_ECDSA_SHA256, + }, + ]); + }); + + it("identifies the leaf by its identifier, not by its position", () => { + const block = decoded( + signedDataBlock({ + certificates: [ROOT_CERTIFICATE, LEAF_CERTIFICATE], + signerInfos: [signerInfo({ names: LEAF_CERTIFICATE })], + }), + ); + expect(block.signerInfos[0]?.certificateIndex).toBe(1); + }); + + it("keeps a SignerInfo whose certificate the block lacks as unidentified", () => { + const block = decoded( + signedDataBlock({ + certificates: [ROOT_CERTIFICATE], + signerInfos: [signerInfo({ names: LEAF_CERTIFICATE })], + }), + ); + expect(block.signerInfos[0]?.certificateIndex).toBeNull(); + expect(block.certificates.map(sha256)).toEqual([ROOT_SHA256]); + }); + + it("accepts BER indefinite lengths on constructed elements", () => { + const inner = signedDataBlock({ + certificates: [LEAF_CERTIFICATE], + signerInfos: [signerInfo({ names: LEAF_CERTIFICATE })], + }); + // Re-wrap the outer ContentInfo with an indefinite length. + const definite = decoded(inner); + const header = inner[1]! > 0x80 ? 2 + (inner[1]! & 0x7f) : 2; + const ber = indefinite(0x30, inner.subarray(header)); + const block = decoded(ber); + expect(block.signerInfos).toEqual(definite.signerInfos); + expect(block.certificates.map(sha256)).toEqual([LEAF_SHA256]); + }); + + it("reads the ContentInfo and counts trailing bytes, as apksig ignores them", () => { + expect(decoded(OPENSSL_BLOCK_ISSUER_SERIAL).trailingBytes).toBe(0); + const block = decoded( + Uint8Array.from([...OPENSSL_BLOCK_ISSUER_SERIAL, 0, 0xff, 7]), + ); + expect(block.trailingBytes).toBe(3); + expect(block.signerInfos[0]?.certificateIndex).toBe(0); + expect(block.certificates.map(sha256)).toEqual([LEAF_SHA256, ROOT_SHA256]); + }); + + it("reports malformed structure instead of an empty signer", () => { + expect(errorOf(OPENSSL_BLOCK_ISSUER_SERIAL.subarray(0, 200)).kind).toBe( + "malformed-pkcs7", + ); + expect(errorOf(Uint8Array.from([0x78])).kind).toBe("malformed-pkcs7"); + expect( + errorOf( + signedDataBlock({ + certificates: [LEAF_CERTIFICATE], + signerInfos: [], + contentType: "1.2.840.113549.1.7.1", + }), + ), + ).toEqual({ + kind: "malformed-pkcs7", + detail: "contentType 1.2.840.113549.1.7.1 is not SignedData", + }); + expect( + errorOf( + signedDataBlock({ + certificates: [tlv(0xa1, Uint8Array.from([0x05, 0x00]))], + signerInfos: [], + }), + ).kind, + ).toBe("malformed-pkcs7"); + // An unterminated indefinite length must not be read as complete. + expect(errorOf(Uint8Array.from([0x30, 0x80, 0x05, 0x00])).kind).toBe( + "malformed-pkcs7", + ); + }); + + it("refuses certificate sets, SignerInfos and element counts past their bounds", () => { + const many = Array.from( + { length: MAX_V1_BLOCK_CERTIFICATES + 1 }, + () => LEAF_CERTIFICATE, + ); + expect( + errorOf(signedDataBlock({ certificates: many, signerInfos: [] })), + ).toEqual({ + kind: "budget-exceeded", + resource: "v1-block-certificates", + limit: MAX_V1_BLOCK_CERTIFICATES, + }); + const infos = Array.from({ length: MAX_V1_SIGNER_INFOS + 1 }, () => + signerInfo({ names: LEAF_CERTIFICATE }), + ); + expect( + errorOf( + signedDataBlock({ + certificates: [LEAF_CERTIFICATE], + signerInfos: infos, + }), + ).kind, + ).toBe("budget-exceeded"); + const flood = new Uint8Array(2 * (MAX_DER_ELEMENTS + 1)).fill(0); + for (let i = 0; i < flood.length; i += 2) flood[i] = 0x05; + expect( + errorOf( + signedDataBlock({ + certificates: [], + signerInfos: [tlv(0x30, flood)], + }), + ), + ).toEqual({ + kind: "budget-exceeded", + resource: "der-elements", + limit: MAX_DER_ELEMENTS, + }); + // Deep nesting is bounded before recursion can exhaust the stack. + let nested: Uint8Array = Uint8Array.from([0x05, 0x00]); + for (let i = 0; i < 64; i += 1) nested = indefinite(0x30, nested); + expect(errorOf(nested)).toEqual({ + kind: "malformed-pkcs7", + detail: "nesting deeper than 32", + }); + }); +}); diff --git a/src/domain/apkSignature/apkV1SignatureBlock.ts b/src/domain/apkSignature/apkV1SignatureBlock.ts new file mode 100644 index 0000000000..a36bf157c2 --- /dev/null +++ b/src/domain/apkSignature/apkV1SignatureBlock.ts @@ -0,0 +1,410 @@ +/** + * Decode the PKCS#7 / CMS `SignedData` inside a v1 (JAR) signature block + * (`META-INF/*.RSA|DSA|EC`) far enough to name its signers: the raw DER of + * every certificate it carries and, per `SignerInfo`, which of those + * certificates it identifies (by `issuerAndSerialNumber` or + * `subjectKeyIdentifier`) and its digest and signature algorithm OIDs. + * + * This owns the DER structure only. Certificate facts (fingerprint, subject) + * stay with the adapter's X.509 owner, and no signature is verified: a + * `SignerInfo` naming a certificate is what the block declares. + * + * Dependency-free and bounded. Every tag and length is checked against its + * enclosing element; BER indefinite lengths (which some JAR signers emit) are + * accepted for constructed elements, within a nesting depth and a total + * element budget, so a hostile block cannot drive unbounded work. Counts past + * format-justified caps are a distinct `budget-exceeded` error, never + * conflated with malformed input. + */ +import { type Result, err, ok } from "../result.js"; + +export type ApkV1BlockError = + | { readonly kind: "malformed-pkcs7"; readonly detail: string } + | { + readonly kind: "budget-exceeded"; + readonly resource: + | "v1-block-certificates" + | "v1-signer-infos" + | "der-elements"; + readonly limit: number; + }; + +/** Certificates in one block: a signer leaf plus a short chain is the norm. */ +export const MAX_V1_BLOCK_CERTIFICATES = 32; +/** SignerInfos in one block; apksig verifies one, more is pathological. */ +export const MAX_V1_SIGNER_INFOS = 32; +/** Nesting depth; SignedData → X.509 extensions is under 12 levels deep. */ +const MAX_DER_DEPTH = 32; +/** Elements read while decoding one block; bounds work for any byte length. */ +export const MAX_DER_ELEMENTS = 65_536; + +export interface ApkV1SignerInfo { + readonly identifiedBy: "issuer-and-serial" | "subject-key-identifier"; + /** Index into `certificates` of the certificate the SignerInfo names; null when none matches. */ + readonly certificateIndex: number | null; + readonly digestAlgorithmOid: string; + readonly signatureAlgorithmOid: string; +} + +export interface ApkV1SignedData { + /** Raw DER of each certificate in `SignedData.certificates`, in block order. */ + readonly certificates: readonly Uint8Array[]; + readonly signerInfos: readonly ApkV1SignerInfo[]; + /** + * Bytes after the ContentInfo. apksig's BER parser reads one top-level value + * and ignores what follows, so they do not make the block unreadable. + */ + readonly trailingBytes: number; +} + +const OID_SIGNED_DATA = "1.2.840.113549.1.7.2"; +const OID_SUBJECT_KEY_IDENTIFIER = "2.5.29.14"; + +const TAG = { + integer: 0x02, + octetString: 0x04, + oid: 0x06, + sequence: 0x30, + set: 0x31, + context0: 0xa0, + context1: 0xa1, + context3: 0xa3, + /** `[0] IMPLICIT SubjectKeyIdentifier` (primitive). */ + context0Primitive: 0x80, +} as const; + +interface Tlv { + readonly tag: number; + readonly start: number; + readonly contentStart: number; + readonly contentEnd: number; + readonly end: number; + readonly depth: number; +} + +/** Expected failures inside one decode; converted to a Result at the boundary. */ +class BlockFailure { + constructor(readonly error: ApkV1BlockError) {} +} +const malformed = (detail: string): BlockFailure => + new BlockFailure({ kind: "malformed-pkcs7", detail }); + +class DerReader { + #elements = 0; + constructor(readonly bytes: Uint8Array) {} + + /** One TLV starting at `at`, which must end at or before `limit`. */ + read(at: number, limit: number, depth: number): Tlv { + if (depth > MAX_DER_DEPTH) + throw malformed(`nesting deeper than ${String(MAX_DER_DEPTH)}`); + this.#elements += 1; + if (this.#elements > MAX_DER_ELEMENTS) + throw new BlockFailure({ + kind: "budget-exceeded", + resource: "der-elements", + limit: MAX_DER_ELEMENTS, + }); + if (at + 2 > limit) + throw malformed(`element at byte ${String(at)} is truncated`); + const tag = this.bytes[at] ?? 0; + if ((tag & 0x1f) === 0x1f) + throw malformed(`high-tag-number form at byte ${String(at)}`); + const first = this.bytes[at + 1] ?? 0; + let contentStart = at + 2; + if (first === 0x80) { + if ((tag & 0x20) === 0) + throw malformed( + `indefinite length on a primitive at byte ${String(at)}`, + ); + // BER indefinite length: children run to an end-of-contents marker. + let child = contentStart; + for (;;) { + if (child + 2 > limit) + throw malformed( + `indefinite element at byte ${String(at)} has no end-of-contents`, + ); + if (this.bytes[child] === 0 && this.bytes[child + 1] === 0) + return { + tag, + start: at, + contentStart, + contentEnd: child, + end: child + 2, + depth, + }; + child = this.read(child, limit, depth + 1).end; + } + } + let length = first; + if (first > 0x80) { + const count = first & 0x7f; + if (count > 4) + throw malformed( + `length of ${String(count)} bytes at byte ${String(at)}`, + ); + if (contentStart + count > limit) + throw malformed(`length at byte ${String(at)} is truncated`); + length = 0; + for (let i = 0; i < count; i += 1) + length = length * 256 + (this.bytes[contentStart + i] ?? 0); + contentStart += count; + } + const end = contentStart + length; + if (end > limit) + throw malformed( + `element at byte ${String(at)} runs past its enclosing element`, + ); + return { tag, start: at, contentStart, contentEnd: end, end, depth }; + } + + children(parent: Tlv): Tlv[] { + const out: Tlv[] = []; + for (let at = parent.contentStart; at < parent.contentEnd;) { + const child = this.read(at, parent.contentEnd, parent.depth + 1); + out.push(child); + at = child.end; + } + return out; + } + + content(tlv: Tlv): Uint8Array { + return this.bytes.subarray(tlv.contentStart, tlv.contentEnd); + } + + raw(tlv: Tlv): Uint8Array { + return this.bytes.subarray(tlv.start, tlv.end); + } +} + +const expectTag = (tlv: Tlv | undefined, tag: number, what: string): Tlv => { + if (tlv === undefined) throw malformed(`${what} is missing`); + if (tlv.tag !== tag) + throw malformed( + `${what} has tag 0x${tlv.tag.toString(16)}, expected 0x${tag.toString(16)}`, + ); + return tlv; +}; + +/** Dotted OID text; arcs are accumulated as BigInt so no arc overflows. */ +const oidText = (content: Uint8Array): string => { + if (content.length === 0 || content.length > 64) + throw malformed(`object identifier of ${String(content.length)} bytes`); + const arcs: bigint[] = []; + let value = 0n; + for (const [index, byte] of content.entries()) { + value = (value << 7n) | BigInt(byte & 0x7f); + if ((byte & 0x80) !== 0) { + if (index === content.length - 1) + throw malformed("object identifier is truncated"); + continue; + } + if (arcs.length === 0) { + const first = value < 80n ? value / 40n : 2n; + arcs.push(first, value - first * 40n); + } else arcs.push(value); + value = 0n; + } + return arcs.join("."); +}; + +const algorithmOid = (reader: DerReader, tlv: Tlv | undefined, what: string) => + oidText( + reader.content( + expectTag( + reader.children(expectTag(tlv, TAG.sequence, what))[0], + TAG.oid, + `${what} algorithm`, + ), + ), + ); + +const equalBytes = (a: Uint8Array, b: Uint8Array): boolean => + a.length === b.length && a.every((byte, i) => byte === b[i]); + +/** The X.509 facts a SignerInfo can name: issuer Name DER, serial and SKI. */ +interface CertificateIdentity { + readonly issuer: Uint8Array; + readonly serial: Uint8Array; + readonly subjectKeyIdentifier: Uint8Array | null; +} + +/** + * Read issuer, serial and subjectKeyIdentifier from a certificate's + * tbsCertificate. A certificate that does not have this shape cannot be named + * by a SignerInfo (null) but is still reported by the adapter's X.509 owner. + */ +const certificateIdentity = ( + reader: DerReader, + certificate: Tlv, +): CertificateIdentity | null => { + try { + const tbs = expectTag( + reader.children(certificate)[0], + TAG.sequence, + "tbsCertificate", + ); + const fields = reader.children(tbs); + const offset = fields[0]?.tag === TAG.context0 ? 1 : 0; + const serial = expectTag(fields[offset], TAG.integer, "serialNumber"); + const issuer = expectTag(fields[offset + 2], TAG.sequence, "issuer"); + let subjectKeyIdentifier: Uint8Array | null = null; + const extensions = fields.find((field) => field.tag === TAG.context3); + if (extensions !== undefined) { + const list = expectTag( + reader.children(extensions)[0], + TAG.sequence, + "extensions", + ); + for (const extension of reader.children(list)) { + const parts = reader.children(extension); + const id = expectTag(parts[0], TAG.oid, "extension id"); + if (oidText(reader.content(id)) !== OID_SUBJECT_KEY_IDENTIFIER) + continue; + const value = expectTag(parts.at(-1), TAG.octetString, "extnValue"); + const inner = reader.read(value.contentStart, value.contentEnd, 0); + subjectKeyIdentifier = reader.content( + expectTag(inner, TAG.octetString, "keyIdentifier"), + ); + } + } + return { + issuer: reader.raw(issuer), + serial: reader.content(serial), + subjectKeyIdentifier, + }; + } catch (cause: unknown) { + if (cause instanceof BlockFailure && cause.error.kind === "malformed-pkcs7") + return null; + throw cause; + } +}; + +const signerInfo = ( + reader: DerReader, + tlv: Tlv, + identities: readonly (CertificateIdentity | null)[], + index: number, +): ApkV1SignerInfo => { + const what = `signerInfo ${String(index)}`; + const fields = reader.children(expectTag(tlv, TAG.sequence, what)); + expectTag(fields[0], TAG.integer, `${what} version`); + const sid = fields[1]; + let identifiedBy: ApkV1SignerInfo["identifiedBy"]; + let certificateIndex: number; + if (sid?.tag === TAG.sequence) { + const parts = reader.children(sid); + const issuer = reader.raw( + expectTag(parts[0], TAG.sequence, `${what} issuer`), + ); + const serial = reader.content( + expectTag(parts[1], TAG.integer, `${what} serialNumber`), + ); + identifiedBy = "issuer-and-serial"; + certificateIndex = identities.findIndex( + (identity) => + identity !== null && + equalBytes(identity.issuer, issuer) && + equalBytes(identity.serial, serial), + ); + } else if (sid?.tag === TAG.context0Primitive) { + const keyId = reader.content(sid); + identifiedBy = "subject-key-identifier"; + certificateIndex = identities.findIndex( + (identity) => + identity?.subjectKeyIdentifier != null && + equalBytes(identity.subjectKeyIdentifier, keyId), + ); + } else throw malformed(`${what} has no supported signer identifier`); + const digestAlgorithmOid = algorithmOid( + reader, + fields[2], + `${what} digestAlgorithm`, + ); + // signedAttrs ([0] IMPLICIT) is optional before signatureAlgorithm. + const next = fields[3]?.tag === TAG.context0 ? 4 : 3; + const signatureAlgorithmOid = algorithmOid( + reader, + fields[next], + `${what} signatureAlgorithm`, + ); + expectTag(fields[next + 1], TAG.octetString, `${what} signature`); + return { + identifiedBy, + certificateIndex: certificateIndex < 0 ? null : certificateIndex, + digestAlgorithmOid, + signatureAlgorithmOid, + }; +}; + +const decode = (bytes: Uint8Array): ApkV1SignedData => { + const reader = new DerReader(bytes); + const contentInfo = expectTag( + reader.read(0, bytes.length, 0), + TAG.sequence, + "ContentInfo", + ); + const [contentType, explicit] = reader.children(contentInfo); + const type = oidText( + reader.content(expectTag(contentType, TAG.oid, "contentType")), + ); + if (type !== OID_SIGNED_DATA) + throw malformed(`contentType ${type} is not SignedData`); + const signedData = expectTag( + reader.children(expectTag(explicit, TAG.context0, "content"))[0], + TAG.sequence, + "SignedData", + ); + const fields = reader.children(signedData); + expectTag(fields[0], TAG.integer, "SignedData version"); + expectTag(fields[1], TAG.set, "digestAlgorithms"); + expectTag(fields[2], TAG.sequence, "encapContentInfo"); + let at = 3; + const certificates: Tlv[] = []; + if (fields[at]?.tag === TAG.context0) { + for (const choice of reader.children(fields[at] as Tlv)) { + // Other CertificateChoices (extended, attribute, other) carry no X.509 signer. + if (choice.tag !== TAG.sequence) + throw malformed( + `certificate choice with tag 0x${choice.tag.toString(16)} is not an X.509 certificate`, + ); + if (certificates.length >= MAX_V1_BLOCK_CERTIFICATES) + throw new BlockFailure({ + kind: "budget-exceeded", + resource: "v1-block-certificates", + limit: MAX_V1_BLOCK_CERTIFICATES, + }); + certificates.push(choice); + } + at += 1; + } + if (fields[at]?.tag === TAG.context1) at += 1; + const infos = reader.children(expectTag(fields[at], TAG.set, "signerInfos")); + if (infos.length > MAX_V1_SIGNER_INFOS) + throw new BlockFailure({ + kind: "budget-exceeded", + resource: "v1-signer-infos", + limit: MAX_V1_SIGNER_INFOS, + }); + const identities = certificates.map((certificate) => + certificateIdentity(reader, certificate), + ); + return { + certificates: certificates.map((certificate) => reader.raw(certificate)), + signerInfos: infos.map((info, index) => + signerInfo(reader, info, identities, index), + ), + trailingBytes: bytes.length - contentInfo.end, + }; +}; + +/** Decode one v1 signature block's PKCS#7 SignedData. Nothing is verified. */ +export const decodeV1SignatureBlock = ( + bytes: Uint8Array, +): Result => { + try { + return ok(decode(bytes)); + } catch (cause: unknown) { + if (cause instanceof BlockFailure) return err(cause.error); + throw cause; + } +}; diff --git a/src/domain/apkSignature/zipEntryName.test.ts b/src/domain/apkSignature/zipEntryName.test.ts new file mode 100644 index 0000000000..9fb649177b --- /dev/null +++ b/src/domain/apkSignature/zipEntryName.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { decodeZipName, sameZipName } from "./zipEntryName.js"; + +const hex = (text: string): Uint8Array => + Uint8Array.from(Buffer.from(text, "hex")); +const units = (text: string): string[] => + [...text].map((c) => (c.codePointAt(0) ?? 0).toString(16).padStart(4, "0")); + +describe("ZIP entry names as apksig decodes them", () => { + // Expected code points from OpenJDK 25's new String(bytes, UTF_8). + it.each([ + ["64ff", ["0064", "fffd"]], + ["c080", ["fffd", "fffd"]], + ["e08080", ["fffd", "fffd", "fffd"]], + // Encoded surrogates: one replacement for Java, three for WHATWG. + ["eda080", ["fffd"]], + ["edbfbf", ["fffd"]], + ["eda0", ["fffd"]], + ["edb080ed8080", ["fffd", "d000"]], + ["eda041", ["fffd", "0041"]], + ["f0808080", ["fffd", "fffd", "fffd", "fffd"]], + ["f4908080", ["fffd", "fffd", "fffd", "fffd"]], + ["f888808080", ["fffd", "fffd", "fffd", "fffd", "fffd"]], + ["e28241", ["fffd", "0041"]], + ["f09f98", ["fffd"]], + ["f09f9841", ["fffd", "0041"]], + ["f09041", ["fffd", "0041"]], + ["e180c280", ["fffd", "0080"]], + ["f09080c0", ["fffd", "fffd"]], + ["f09f9880", ["1f600"]], + ["636166c3a9", ["0063", "0061", "0066", "00e9"]], + // Java keeps a leading byte-order mark as U+FEFF. + ["efbbbf41", ["feff", "0041"]], + ["efbbbfefbbbf", ["feff", "feff"]], + ])("decodes %s as Java does", (stored, expected) => { + expect(units(decodeZipName(hex(stored)))).toEqual(expected); + }); + + it("matches names that decode to the same Java string", () => { + expect(sameZipName(hex("64ff"), hex("64fe"))).toBe(true); + expect(sameZipName(hex("64ff"), hex("64efbfbd"))).toBe(true); + expect(sameZipName(hex("78eda080"), hex("78ff"))).toBe(true); + expect(sameZipName(hex("78ff"), hex("78ffff"))).toBe(false); + expect(sameZipName(hex("6162"), hex("6162"))).toBe(true); + expect(sameZipName(hex("6162"), hex("6163"))).toBe(false); + }); +}); diff --git a/src/domain/apkSignature/zipEntryName.ts b/src/domain/apkSignature/zipEntryName.ts new file mode 100644 index 0000000000..9ce2a1e15c --- /dev/null +++ b/src/domain/apkSignature/zipEntryName.ts @@ -0,0 +1,203 @@ +/** + * ZIP entry names as apksig reads them: `new String(raw, UTF_8)`, compared as + * Java strings. Java replaces malformed UTF-8 with U+FFFD like WHATWG's + * TextDecoder (one replacement per maximal subpart) except for encoded + * surrogates: Java replaces `ED A0..BF` with its continuation byte (or ending + * the name) as one unit, where WHATWG replaces `ED` and each following byte + * separately. Checked against OpenJDK 25's `String(byte[], UTF_8)`. + * + * A name's canonical bytes are the UTF-8 encoding of that decoded string; a + * well-formed name is its own canonical form. Hashing and comparison walk the + * stored bytes in place, one unit at a time; only decoding a malformed name + * for display builds its canonical bytes. + */ + +// Java keeps a leading U+FEFF, so the decoder must not strip a BOM. +const NAME_DECODER = new TextDecoder("utf-8", { + fatal: false, + ignoreBOM: true, +}); + +/** U+FFFD encoded as UTF-8. */ +const REPLACEMENT = [0xef, 0xbf, 0xbd] as const; + +const isContinuation = (b: number): boolean => (b & 0xc0) === 0x80; + +/** A continuation, C0/C1 or F5..FF byte where a sequence starts: replaced alone. */ +const isLoneInvalid = (b: number): boolean => + b >= 0x80 && (b < 0xc2 || b > 0xf4); + +/** + * The byte length of the unit at `i`: positive for a well-formed sequence, + * negative for the bytes Java replaces with one U+FFFD (as JDK + * `String.decodeUTF8_UTF16` advances). + */ +const nextUnit = (bytes: Uint8Array, i: number, stop: number): number => { + const b1 = bytes[i] ?? 0; + if (b1 < 0x80) return 1; + if (isLoneInvalid(b1)) return -1; + const remaining = stop - i; + const b2 = remaining > 1 ? (bytes[i + 1] ?? 0) : 0; + const b3 = remaining > 2 ? (bytes[i + 2] ?? 0) : 0; + if (b1 >= 0xc2 && b1 <= 0xdf) + return remaining > 1 && isContinuation(b2) ? 2 : -1; + if ((b1 & 0xf0) === 0xe0) { + const badSecond = + (b1 === 0xe0 && (b2 & 0xe0) === 0x80) || !isContinuation(b2); + if (remaining > 2) { + if (badSecond) return -1; + if (!isContinuation(b3)) return -2; + // An encoded surrogate decodes, then is replaced as a whole. + return b1 === 0xed && b2 >= 0xa0 ? -3 : 3; + } + // Truncated at the end: a bad second byte alone, else the whole tail. + return remaining === 2 && badSecond ? -1 : -remaining; + } + if (b1 >= 0xf0 && b1 <= 0xf7) { + if (remaining === 1) return -1; + const badSecond = + b1 > 0xf4 || + (b1 === 0xf0 && (b2 < 0x90 || b2 > 0xbf)) || + (b1 === 0xf4 && (b2 & 0xf0) !== 0x80) || + !isContinuation(b2); + if (badSecond) return -1; + if (remaining > 3) { + if (!isContinuation(b3)) return -2; + return isContinuation(bytes[i + 3] ?? 0) ? 4 : -3; + } + // Truncated at the end: up to a bad third byte, else the whole tail. + return remaining === 3 && !isContinuation(b3) ? -2 : -remaining; + } + return -1; +}; + +/** True when the stored name is well-formed UTF-8 (its own canonical form). */ +export const isCanonicalZipName = ( + bytes: Uint8Array, + start: number, + stop: number, +): boolean => { + for (let i = start; i < stop;) { + const unit = nextUnit(bytes, i, stop); + if (unit < 0) return false; + i += unit; + } + return true; +}; + +const FNV_OFFSET = 0x811c9dc5; +const FNV_PRIME = 0x01000193; + +/** 32-bit FNV-1a over a name's canonical bytes, read in place. */ +export const canonicalNameHash = ( + bytes: Uint8Array, + start: number, + stop: number, + canonical: boolean, +): number => { + let hash = FNV_OFFSET; + if (canonical) { + for (let k = start; k < stop; k += 1) + hash = Math.imul(hash ^ (bytes[k] ?? 0), FNV_PRIME); + return hash; + } + for (let i = start; i < stop;) { + const unit = nextUnit(bytes, i, stop); + if (unit > 0) + for (let k = i; k < i + unit; k += 1) + hash = Math.imul(hash ^ (bytes[k] ?? 0), FNV_PRIME); + else { + hash = Math.imul(hash ^ 0xef, FNV_PRIME); + hash = Math.imul(hash ^ 0xbf, FNV_PRIME); + hash = Math.imul(hash ^ 0xbd, FNV_PRIME); + } + i += Math.abs(unit); + } + return hash; +}; + +/** A stored name: its bytes within a buffer. */ +export interface StoredZipName { + readonly bytes: Uint8Array; + readonly start: number; + readonly stop: number; +} + +/** Canonical byte `k` of the unit at `i` (U+FFFD for a replaced one), or -1 past it. */ +const unitByte = ( + bytes: Uint8Array, + i: number, + unit: number, + k: number, +): number => { + if (unit < 0) return REPLACEMENT[k] ?? -1; + return k < unit ? (bytes[i + k] ?? 0) : -1; +}; + +/** + * Byte order of two stored names' canonical forms, compared one unit at a + * time in place: work is linear in the stored bytes and nothing is copied. + * UTF-8 sequences are prefix-free, so two units differ within their bytes or + * not at all. + */ +export const compareCanonicalNames = ( + a: StoredZipName, + b: StoredZipName, +): number => { + let i = a.start; + let j = b.start; + while (i < a.stop && j < b.stop) { + const x = a.bytes[i] ?? 0; + const y = b.bytes[j] ?? 0; + // Equal ASCII, or two bytes each replaced alone: the same canonical bytes. + if ((x === y && x < 0x80) || (isLoneInvalid(x) && isLoneInvalid(y))) { + i += 1; + j += 1; + continue; + } + const u = nextUnit(a.bytes, i, a.stop); + const v = nextUnit(b.bytes, j, b.stop); + if (u > 0 || v > 0) + for (let k = 0; k < 4; k += 1) { + const p = unitByte(a.bytes, i, u, k); + const q = unitByte(b.bytes, j, v, k); + if (p !== q) return p - q; + if (p < 0) break; + } + i += Math.abs(u); + j += Math.abs(v); + } + return (i < a.stop ? 1 : 0) - (j < b.stop ? 1 : 0); +}; + +/** The name as apksig's `new String(raw, UTF_8)` decodes it. */ +export const decodeZipName = (raw: Uint8Array): string => { + if (isCanonicalZipName(raw, 0, raw.length)) return NAME_DECODER.decode(raw); + // Re-encode the replacements once, then decode the well-formed result. + let length = 0; + for (let i = 0; i < raw.length;) { + const unit = nextUnit(raw, i, raw.length); + length += unit > 0 ? unit : REPLACEMENT.length; + i += Math.abs(unit); + } + const canonical = new Uint8Array(length); + let at = 0; + for (let i = 0; i < raw.length;) { + const unit = nextUnit(raw, i, raw.length); + if (unit > 0) canonical.set(raw.subarray(i, i + unit), at); + else canonical.set(REPLACEMENT, at); + at += unit > 0 ? unit : REPLACEMENT.length; + i += Math.abs(unit); + } + return NAME_DECODER.decode(canonical); +}; + +const whole = (bytes: Uint8Array): StoredZipName => ({ + bytes, + start: 0, + stop: bytes.length, +}); + +/** The two stored names decode to the same Java string, as apksig compares them. */ +export const sameZipName = (a: Uint8Array, b: Uint8Array): boolean => + compareCanonicalNames(whole(a), whole(b)) === 0; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index b76b367d89..b4b19dac93 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -10,6 +10,9 @@ import { registerSignalTools } from "./registerSignalTools.js"; import type { WasmArtifactService } from "../application/wasm/WasmArtifactService.js"; import { wasmArtifactModule } from "../composition/wasm.js"; import { registerWasmTools } from "./registerWasmTools.js"; +import type { ApkSignatureService } from "../application/apkSignature/ApkSignatureService.js"; +import { apkSignatureModule } from "../composition/apkSignature.js"; +import { registerApkSignatureTools } from "./registerApkSignatureTools.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { captureProviderEnvironment } from "./providerEnvironment.js"; import { @@ -191,6 +194,7 @@ export interface CreateServerOptions { readonly packetCapture?: PacketCaptureService; readonly signalRecording?: SignalRecordingService; readonly wasmArtifact?: WasmArtifactService; + readonly apkSignature?: ApkSignatureService; readonly providerEnvironment?: Readonly; readonly evmInterface?: EvmInterfaceService; readonly sqliteDatabase?: SqliteDatabaseService; @@ -434,6 +438,10 @@ export const createServer = ( environment, options.wasmArtifact, ); + const apkSignature = apkSignatureModule.create( + environment, + options.apkSignature, + ); const packetCapture = options.packetCapture ?? createPacketCaptureService(environment); const signalRecording = @@ -455,6 +463,7 @@ export const createServer = ( cutter.close(), fridaInstrumentation.closeAll(), wasmArtifact.close(), + apkSignature.close(), packetCapture.close(), signalRecording.close(), apktool.close(), @@ -674,6 +683,7 @@ export const createServer = ( toolContext, android, wasmArtifact, + apkSignature, evmInterface, jeb, firmwareAnalysis, @@ -789,6 +799,7 @@ const registerConfiguredAnalysisTools = ( }: ServerToolContext, android: AndroidAnalysisPort, wasmArtifact: Pick, + apkSignature: Pick, evmInterface: EvmInterfaceService, jeb: JebAnalysisPort, firmwareAnalysis: FirmwareAnalysisService, @@ -900,6 +911,14 @@ const registerConfiguredAnalysisTools = ( withAdmittedAnalysis, evidenceById, ); + registerApkSignatureTools( + server, + apkSignature, + toolLogger, + recordEvidence, + withAdmittedAnalysis, + evidenceById, + ); registerEvmTools( server, evmInterface, diff --git a/src/server/http/NodeHttpRequestOwner.ts b/src/server/http/NodeHttpRequestOwner.ts index 5563245fdc..315e395cb8 100644 --- a/src/server/http/NodeHttpRequestOwner.ts +++ b/src/server/http/NodeHttpRequestOwner.ts @@ -158,10 +158,9 @@ export class NodeHttpRequestOwner { async drain(): Promise { // An exchange can register cancellation after its route has returned, // including when a late Response finds that its client already left. - while (this.#exchanges.size > 0) - await Promise.allSettled([...this.#exchanges]); + while (this.#exchanges.size > 0) await Promise.allSettled(this.#exchanges); while (this.#bodyCleanups.size > 0) - await Promise.allSettled([...this.#bodyCleanups]); + await Promise.allSettled(this.#bodyCleanups); if (this.#cleanupFailures.length > 0) throw new AnalysisResourceConstraintError( "mcp_http_response_cleanup", diff --git a/src/server/registerApkSignatureTools.ts b/src/server/registerApkSignatureTools.ts new file mode 100644 index 0000000000..f446aa75b7 --- /dev/null +++ b/src/server/registerApkSignatureTools.ts @@ -0,0 +1,34 @@ +import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { ApkSignatureService } from "../application/apkSignature/ApkSignatureService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { apkSignatureModule } from "../composition/apkSignature.js"; +import type { Logger } from "pino"; +import { createEvidenceToolHandler } from "./contractToolHandler.js"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Bind the named APK-signature contract to shared validation and session Evidence. */ +export const registerApkSignatureTools = ( + server: EvidenceMcpServer, + service: Pick, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, + evidenceById?: EvidenceLookup, +): void => { + const contract = apkSignatureModule.contract; + const handler = createEvidenceToolHandler( + server, + (input, options) => + service.inspect(input, options, { recordEvidence, evidenceById }), + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + handler(contract), + ); +}; diff --git a/tests/acceptance/apkSignatureV1Cli.test.ts b/tests/acceptance/apkSignatureV1Cli.test.ts new file mode 100644 index 0000000000..8c18537e26 --- /dev/null +++ b/tests/acceptance/apkSignatureV1Cli.test.ts @@ -0,0 +1,591 @@ +import { apkSignatureResultSchema } from "../../src/domain/apkSignature/apkSignatureReport.js"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect } from "vitest"; + +import { AnalysisProviderRegistry } from "../../src/application/binary/AnalysisProviderRegistry.js"; +import { composeBinarySession } from "../../src/application/binary/BinarySessionComposition.js"; +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { createServer } from "../../src/server/createServer.js"; +import { + APK_SIGNATURE_V2_ID, + APK_SIGNATURE_V3_ID, + LEAF_CERTIFICATE, + LEAF_SHA256, + LEAF_SUBJECT, + OPENSSL_BLOCK_ISSUER_SERIAL, + OPENSSL_BLOCK_KEY_ID, + ROOT_CERTIFICATE, + v2SchemeValue, + v3SchemeValue, + withSigningBlock, + zipEntryOffsets, + zipWith, +} from "../fixtures/apkSignature/v1SignatureBlock.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +const completeEvidence = (value: unknown) => { + const evidence = toolContract("inspect_apk_signature").outputSchema.parse( + value, + ); + return { + ...evidence, + normalized_result: apkSignatureResultSchema.parse( + evidence.normalized_result, + ), + }; +}; + +cliTest( + "names the v1 signer certificate and a v1/v2 signer mismatch identically through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apk-v1-signer-"); + const v1Zip = await zipWith([ + { name: "classes.dex", data: "dex" }, + { name: "META-INF/MANIFEST.MF", data: "Manifest-Version: 1.0" }, + { name: "META-INF/CERT.SF", data: "Signature-Version: 1.0" }, + { name: "META-INF/CERT.EC", data: OPENSSL_BLOCK_ISSUER_SERIAL }, + ]); + const v1Only = join(root, "v1-only.apk"); + await writeFile(v1Only, v1Zip); + // The v2 block names a different certificate than the JAR signature. + const mixed = join(root, "v1-v2-mismatch.apk"); + await writeFile( + mixed, + withSigningBlock(v1Zip, [ + { id: APK_SIGNATURE_V2_ID, value: v2SchemeValue(ROOT_CERTIFICATE) }, + ]), + ); + + // Rotated v1 + v3 without v2: v1 keeps the original key (the lineage's + // first node), v3 signs with the newest. That is the expected layout. + const rotated = join(root, "v1-v3-rotated.apk"); + await writeFile( + rotated, + withSigningBlock(v1Zip, [ + { + id: APK_SIGNATURE_V3_ID, + value: v3SchemeValue(ROOT_CERTIFICATE, [ + LEAF_CERTIFICATE, + ROOT_CERTIFICATE, + ]), + }, + ]), + ); + // A lineage that does not start at the v1 key is still a mismatch. + const misrotated = join(root, "v1-v3-misrotated.apk"); + await writeFile( + misrotated, + withSigningBlock(v1Zip, [ + { + id: APK_SIGNATURE_V3_ID, + value: v3SchemeValue(LEAF_CERTIFICATE, [ + ROOT_CERTIFICATE, + LEAF_CERTIFICATE, + ]), + }, + ]), + ); + + const viaCli = async (path: string) => { + const result = await cli.run({ + arguments: ["inspect-apk-signature", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + return completeEvidence(result.json); + }; + const cliV1 = await viaCli(v1Only); + const cliMixed = await viaCli(mixed); + const cliRotated = await viaCli(rotated); + const cliMisrotated = await viaCli(misrotated); + + expect(cliV1.normalized_result.schemes.v1.signers).toMatchObject([ + { + signature_file: "META-INF/CERT.SF", + block_file: "META-INF/CERT.EC", + certificate: { sha256_fingerprint: LEAF_SHA256, subject: LEAF_SUBJECT }, + decode_error: null, + }, + ]); + expect(cliV1.normalized_result.posture).toMatchObject({ + v1_only: true, + v1_signers_match_block_signers: null, + }); + expect(cliMixed.normalized_result.posture).toMatchObject({ + v1_only: false, + v1_signers_match_block_signers: false, + }); + expect(cliRotated.normalized_result.posture).toMatchObject({ + v1_only: false, + rotation_present: true, + signer_change_declared_by_rotation: false, + v1_signers_match_block_signers: true, + }); + expect( + cliMisrotated.normalized_result.posture.v1_signers_match_block_signers, + ).toBe(false); + expect(cliV1.limitations.join(" ")).toContain("NOT verified"); + + const session = composeBinarySession(new AnalysisProviderRegistry([])); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "apk-v1-signer-parity", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const tool = (await client.listTools()).tools.find( + (t) => t.name === "inspect_apk_signature", + ); + const outputSchema: Record | undefined = + tool?.outputSchema; + if (tool === undefined || outputSchema === undefined) + throw new Error( + "inspect_apk_signature must advertise an output schema", + ); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + for (const [path, fromCli] of [ + [v1Only, cliV1], + [mixed, cliMixed], + [rotated, cliRotated], + ] as const) { + const reply = await client.callTool( + { name: tool.name, arguments: { path, detail: "complete" } }, + { toolDefinition: tool }, + ); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + expect( + ajv.validate(outputSchema, reply.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const evidence = parseEvidence(reply.structuredContent); + expect(evidence.normalized_result).toEqual(fromCli.normalized_result); + expect(evidence.evidence_id).toBe(fromCli.evidence_id); + } + } finally { + await client.close(); + await server.close(); + await session.close(); + } + }, +); + +cliTest( + "keeps a subjectKeyIdentifier v1 signer out of the posture match, as apksig cannot resolve it", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apk-v1-ski-"); + // The same key in v1 and v2, but the JAR block names it by + // subjectKeyIdentifier, which apksig cannot resolve. + const keyIdentified = join(root, "v1-ski-v2.apk"); + await writeFile( + keyIdentified, + withSigningBlock( + await zipWith([ + { name: "META-INF/MANIFEST.MF", data: "Manifest-Version: 1.0" }, + { name: "META-INF/CERT.SF", data: "Signature-Version: 1.0" }, + { name: "META-INF/CERT.EC", data: OPENSSL_BLOCK_KEY_ID }, + ]), + [{ id: APK_SIGNATURE_V2_ID, value: v2SchemeValue(LEAF_CERTIFICATE) }], + ), + ); + + const run = await cli.run({ + arguments: ["inspect-apk-signature", keyIdentified, "--json"], + }); + expect(run.exitCode, run.stderr).toBe(0); + const cliKeyIdentified = completeEvidence(run.json); + + expect(cliKeyIdentified.normalized_result.schemes.v1.signers).toMatchObject( + [ + { + certificate: { sha256_fingerprint: LEAF_SHA256 }, + signer_infos: [ + { + identified_by: "subject-key-identifier", + certificate_sha256: LEAF_SHA256, + }, + ], + decode_error: null, + }, + ], + ); + expect( + cliKeyIdentified.normalized_result.posture.v1_signers_match_block_signers, + ).toBeNull(); + expect(cliKeyIdentified.limitations.join(" ")).toContain( + "META-INF/CERT.EC: a SignerInfo names its certificate by subjectKeyIdentifier, which apksig does not resolve", + ); + }, +); + +cliTest( + "keeps a v1 signer unknown where apksig refuses its signature block entry", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apk-v1-entry-"); + const block = "META-INF/CERT.EC"; + // apksigner writes local headers with sizes, not data descriptors. + const signed = await zipWith( + [ + { name: "classes.dex", data: "dex" }, + { name: "META-INF/MANIFEST.MF", data: "Manifest-Version: 1.0" }, + { name: "META-INF/CERT.SF", data: "Signature-Version: 1.0" }, + { name: block, data: OPENSSL_BLOCK_ISSUER_SERIAL }, + ], + { dataDescriptor: false }, + ); + const padded = await zipWith( + [ + { name: "META-INF/CERT.SF", data: "Signature-Version: 1.0" }, + { + name: block, + data: Uint8Array.from([...OPENSSL_BLOCK_ISSUER_SERIAL, 0, 0]), + }, + ], + { dataDescriptor: false }, + ); + const mutated = async ( + name: string, + mutate: (bytes: Buffer, at: { central: number; local: number }) => void, + ) => { + const bytes = Buffer.from(signed); + mutate(bytes, zipEntryOffsets(signed, block)); + const path = join(root, name); + await writeFile(path, bytes); + return path; + }; + const flagEncrypted = (bytes: Buffer, central: number, local: number) => { + bytes.writeUInt16LE(bytes.readUInt16LE(central + 8) | 1, central + 8); + bytes.writeUInt16LE(bytes.readUInt16LE(local + 6) | 1, local + 6); + }; + // apksig ignores the encryption flag: plain bytes still decode, and + // bytes that really are scrambled fail to decode. + const flagged = await mutated( + "encrypted-flag.apk", + (bytes, { central, local }) => { + flagEncrypted(bytes, central, local); + }, + ); + const cases = [ + [await mutated("intact.apk", () => undefined), null], + [flagged, null], + [ + await mutated("encrypted-bytes.apk", (bytes, { central, local }) => { + flagEncrypted(bytes, central, local); + const start = + local + + 30 + + bytes.readUInt16LE(local + 26) + + bytes.readUInt16LE(local + 28); + for (let i = 0; i < bytes.readUInt32LE(local + 18); i += 1) + bytes.writeUInt8( + (bytes.readUInt8(start + i) ^ 0x5a) & 0xff, + start + i, + ); + }), + /^(malformed PKCS#7: |signature block deflate stream is damaged)/, + ], + [ + await mutated("local-size-plus1.apk", (bytes, { local }) => { + bytes.writeUInt32LE(bytes.readUInt32LE(local + 18) + 1, local + 18); + }), + /^local header compressed size \d+ differs from the central directory's \d+$/, + ], + [ + await mutated("local-name-differs.apk", (bytes, { local }) => { + bytes.write("META-INF/CERT.XY", local + 30, "latin1"); + }), + "local header name differs from the central directory's", + ], + [ + await mutated("zip64-sentinel.apk", (bytes, { central }) => { + bytes.writeUInt32LE(0xffffffff, central + 24); + }), + "Zip64 entry sizes are not read", + ], + ] as const; + // Bytes after the ContentInfo are ignored, as apksig ignores them, and + // reported as a limitation rather than refusing the block. + const paddedPath = join(root, "trailing-bytes.apk"); + await writeFile(paddedPath, padded); + const paddedResult = await cli.run({ + arguments: ["inspect-apk-signature", paddedPath, "--json"], + }); + expect(paddedResult.exitCode, paddedResult.stderr).toBe(0); + const paddedEvidence = completeEvidence(paddedResult.json); + expect(paddedEvidence.normalized_result.schemes.v1.signers).toMatchObject([ + { certificate: { sha256_fingerprint: LEAF_SHA256 }, decode_error: null }, + ]); + expect(paddedEvidence.limitations).toContain( + `${block}: 2 bytes follow the PKCS#7 ContentInfo and were ignored, as apksig ignores them.`, + ); + + const flaggedResult = await cli.run({ + arguments: ["inspect-apk-signature", flagged, "--json"], + }); + expect(flaggedResult.exitCode, flaggedResult.stderr).toBe(0); + expect(completeEvidence(flaggedResult.json).limitations).toContain( + `${block}: the entry declares encryption (general-purpose flag bit 0); apksig ignores the flag, so its bytes were read as stored and the PKCS#7 decoding decides.`, + ); + + const { call } = await connectLocalToolsMcp(); + for (const [path, decodeError] of cases) { + const result = await call("inspect_apk_signature", { + path, + detail: "complete", + }); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + const report = completeEvidence( + result.structuredContent, + ).normalized_result; + const [signer, ...rest] = report.schemes.v1.signers; + expect(rest, path).toEqual([]); + if (decodeError === null) { + expect(signer?.decode_error, path).toBeNull(); + expect(signer?.certificate?.sha256_fingerprint, path).toBe(LEAF_SHA256); + continue; + } + // Unknown, not absent: the signer stays listed without a certificate. + expect(report.schemes.v1.present, path).toBe(true); + expect(signer?.certificate, path).toBeNull(); + if (typeof decodeError === "string") + expect(signer?.decode_error, path).toBe(decodeError); + else expect(signer?.decode_error, path).toMatch(decodeError); + } + }, +); + +cliTest( + "flags any duplicate central-directory name as apksig rejects it", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apk-v1-duplicate-"); + const block = "META-INF/CERT.EC"; + // Two blocks with one name: apksig rejects the v1 signature + // ("Duplicate entry"); REA keeps one signer for the name, unknown. + const duplicated = join(root, "duplicate-names.apk"); + const twoBlocks = Buffer.from( + await zipWith( + [ + { name: "META-INF/CERT.SF", data: "Signature-Version: 1.0" }, + { name: block, data: OPENSSL_BLOCK_ISSUER_SERIAL }, + { name: "META-INF/CERU.EC", data: OPENSSL_BLOCK_KEY_ID }, + ], + { dataDescriptor: false }, + ), + ); + const second = zipEntryOffsets(twoBlocks, "META-INF/CERU.EC"); + twoBlocks.write(block, second.central + 46, "latin1"); + twoBlocks.write(block, second.local + 30, "latin1"); + await writeFile(duplicated, twoBlocks); + const duplicatedResult = await cli.run({ + arguments: ["inspect-apk-signature", duplicated, "--json"], + }); + expect(duplicatedResult.exitCode, duplicatedResult.stderr).toBe(0); + const duplicatedEvidence = completeEvidence(duplicatedResult.json); + expect(duplicatedEvidence.normalized_result.schemes.v1).toMatchObject({ + present: true, + block_files: [block, block], + signers: [ + { + block_file: block, + certificate: null, + decode_error: `duplicate entry: ${block}`, + }, + ], + }); + expect(duplicatedEvidence.limitations.join(" ")).toContain( + `holds 1 name more than once ("${block}")`, + ); + + // apksig rejects the v1 signature for any duplicate name: a duplicated + // manifest makes every signer unknown, and a duplicate outside the signing + // names is reported while the signers stay as declared. + const renamed = async (name: string, from: string, to: string) => { + const archive = Buffer.from( + await zipWith( + [ + { name: "classes.dex", data: "dex" }, + { name: "classes.dey", data: "dex2" }, + { name: "META-INF/MANIFEST.MF", data: "Manifest-Version: 1.0" }, + { name: "META-INF/MANIFEST.MX", data: "Manifest-Version: 1.0" }, + { name: "META-INF/CERT.SF", data: "Signature-Version: 1.0" }, + { name: block, data: OPENSSL_BLOCK_ISSUER_SERIAL }, + ], + { dataDescriptor: false }, + ), + ); + const at = zipEntryOffsets(archive, from); + archive.write(to, at.central + 46, "latin1"); + archive.write(to, at.local + 30, "latin1"); + const path = join(root, name); + await writeFile(path, archive); + const run = await cli.run({ + arguments: ["inspect-apk-signature", path, "--json"], + }); + expect(run.exitCode, run.stderr).toBe(0); + return completeEvidence(run.json); + }; + const manifestTwice = await renamed( + "duplicate-manifest.apk", + "META-INF/MANIFEST.MX", + "META-INF/MANIFEST.MF", + ); + expect(manifestTwice.normalized_result.schemes.v1.signers).toMatchObject([ + { + certificate: null, + decode_error: "duplicate entry: META-INF/MANIFEST.MF", + }, + ]); + expect(manifestTwice.limitations.join(" ")).toContain( + 'holds 1 name more than once ("META-INF/MANIFEST.MF")', + ); + const dexTwice = await renamed( + "duplicate-dex.apk", + "classes.dey", + "classes.dex", + ); + expect(dexTwice.normalized_result.schemes.v1.signers).toMatchObject([ + { certificate: { sha256_fingerprint: LEAF_SHA256 }, decode_error: null }, + ]); + expect(dexTwice.limitations.join(" ")).toContain( + 'holds 1 name more than once ("classes.dex"); apksig rejects the v1 signature of an archive with any duplicate entry ("Duplicate entry")', + ); + }, +); + +/** A central directory with no entry data, ended by a classic or Zip64 EOCD. */ +const directoryOnly = (names: readonly Buffer[]): Buffer => { + const cd = Buffer.concat( + names.flatMap((name) => { + const fixed = Buffer.alloc(46); + fixed.writeUInt32LE(0x02014b50, 0); + fixed.writeUInt16LE(name.length, 28); + return [fixed, name]; + }), + ); + const zip64 = names.length > 0xfffe; + const tail: Buffer[] = []; + if (zip64) { + const record = Buffer.alloc(56); + record.writeUInt32LE(0x06064b50, 0); + record.writeBigUInt64LE(44n, 4); + record.writeBigUInt64LE(BigInt(names.length), 24); + record.writeBigUInt64LE(BigInt(names.length), 32); + record.writeBigUInt64LE(BigInt(cd.length), 40); + const locator = Buffer.alloc(20); + locator.writeUInt32LE(0x07064b50, 0); + locator.writeBigUInt64LE(BigInt(cd.length), 8); + locator.writeUInt32LE(1, 16); + tail.push(record, locator); + } + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(Math.min(names.length, 0xffff), 8); + eocd.writeUInt16LE(Math.min(names.length, 0xffff), 10); + eocd.writeUInt32LE(cd.length, 12); + return Buffer.concat([cd, ...tail, eocd]); +}; + +cliTest( + "names duplicates as apksig decodes and counts them, quoted and bounded", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apk-duplicate-names-"); + const limitationsOf = async (name: string, names: readonly Buffer[]) => { + const path = join(root, name); + await writeFile(path, directoryOnly(names)); + const run = await cli.run({ + arguments: ["inspect-apk-signature", path, "--json"], + }); + expect(run.exitCode, run.stderr).toBe(0); + const { limitations } = completeEvidence(run.json); + for (const note of limitations) expect(note.length).toBeLessThan(4096); + return limitations.filter( + (note) => note.includes("records") || note.includes("more than once"), + ); + }; + const latin1 = (text: string) => Buffer.from(text, "latin1"); + + // apksigner: "Duplicate entry: dup\uFFFD" (Java decodes both as dup + U+FFFD). + expect( + await limitationsOf("malformed.apk", [ + latin1("dup\xff"), + latin1("dup\xfe"), + ]), + ).toEqual([ + expect.stringContaining('holds 1 name more than once ("dup\uFFFD")'), + ]); + // apksigner: "Duplicate entry: " (the empty name). + expect( + await limitationsOf("empty.apk", [Buffer.alloc(0), Buffer.alloc(0)]), + ).toEqual([expect.stringContaining('holds 1 name more than once ("");')]); + + // Long names are cut at 256 bytes with their stored length; the count stays exact. + const long = [ + "L".repeat(0xffff), + ...[..."ABCDEF"].map((c) => c.repeat(60_000)), + ].flatMap((name) => [latin1(name), latin1(name)]); + const [longNote] = await limitationsOf("long.apk", long); + expect(longNote).toContain( + `holds 7 names more than once ("${"L".repeat(256)}"… (65535-byte name), "${"A".repeat(256)}"… (60000-byte name),`, + ); + expect(longNote).toContain( + "… (60000-byte name) and 2 more); apksig rejects", + ); + + // apksig reads only the classic EOCD's 65,535 records of a Zip64 directory. + const many = Array.from({ length: 70_000 }, (_, i) => + latin1(`f${String(i)}`), + ); + const zip64 = await limitationsOf("zip64.apk", [ + ...many, + latin1("f0"), + latin1("f0"), + latin1("f1"), + latin1("f69999"), + ]); + expect(zip64).toEqual([ + 'apksig reads only the first 65535 of the 70004 central-directory records (it takes the count from the classic end-of-central-directory record\'s 16-bit field and ignores the Zip64 count). 3 names ("f0", "f1", "f69999") are duplicated only by records past those, which apksig does not read.', + ]); + }, +); + +cliTest( + "reads a v1 block whose local and central names differ only in malformed bytes Java decodes alike", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apk-v1-malformed-name-"); + // apksig compares the decoded names: CERT\xff and CERT\xfe are both + // CERT + U+FFFD, which also pairs the block with CERT�.SF. + const archive = Buffer.from( + await zipWith( + [ + { name: "META-INF/CERT�.SF", data: "Signature-Version: 1.0" }, + { name: "META-INF/CERTX.EC", data: OPENSSL_BLOCK_ISSUER_SERIAL }, + ], + { dataDescriptor: false }, + ), + ); + const at = zipEntryOffsets(archive, "META-INF/CERTX.EC"); + archive.write("META-INF/CERT\xff.EC", at.central + 46, "latin1"); + archive.write("META-INF/CERT\xfe.EC", at.local + 30, "latin1"); + const path = join(root, "malformed-name.apk"); + await writeFile(path, archive); + const run = await cli.run({ + arguments: ["inspect-apk-signature", path, "--json"], + }); + expect(run.exitCode, run.stderr).toBe(0); + expect( + completeEvidence(run.json).normalized_result.schemes.v1.signers, + ).toMatchObject([ + { + signature_file: "META-INF/CERT�.SF", + block_file: "META-INF/CERT�.EC", + certificate: { sha256_fingerprint: LEAF_SHA256 }, + decode_error: null, + }, + ]); + }, +); diff --git a/tests/acceptance/apkSignatureViewsCliMcp.test.ts b/tests/acceptance/apkSignatureViewsCliMcp.test.ts new file mode 100644 index 0000000000..e2ce8b1034 --- /dev/null +++ b/tests/acceptance/apkSignatureViewsCliMcp.test.ts @@ -0,0 +1,373 @@ +import { readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import type { z } from "zod"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { + apkSignaturePageViewResultSchema, + apkSignatureSummaryViewResultSchema, +} from "../../src/domain/analysisView/analysisView.js"; +import { + apkDigestRowSchema, + type apkSignatureViewRequestSchema, +} from "../../src/domain/apkSignature/apkSignatureQuery.js"; +import { apkSignatureResultSchema } from "../../src/domain/apkSignature/apkSignatureReport.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; +import { + APK_SIGNATURE_V2_ID, + APK_SIGNATURE_V3_ID, + LEAF_CERTIFICATE, + LEAF_SHA256, + ROOT_CERTIFICATE, + signedDataBlock, + signerInfo, + v2SchemeValue, + v3SchemeValue, + withSigningBlock, + zipWith, +} from "../fixtures/apkSignature/v1SignatureBlock.js"; + +type Call = Awaited>["call"]; +const pageFrom = (value: unknown) => + apkSignaturePageViewResultSchema.parse( + parseEvidence(value).normalized_result, + ); +const readPage = async ( + call: Call, + parent: string, + view: z.input, +) => { + const reply = await call("inspect_analysis_view", { + source: { kind: "retained-evidence", evidence_id: parent }, + view, + }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + return pageFrom(reply.structuredContent); +}; +const signedFixture = async (root: string) => { + const path = join(root, "selected.apk"); + await writeFile( + path, + withSigningBlock(await zipWith([{ name: "classes.dex", data: "opaque" }]), [ + { id: APK_SIGNATURE_V2_ID, value: v2SchemeValue(ROOT_CERTIFICATE) }, + { + id: APK_SIGNATURE_V3_ID, + value: v3SchemeValue(LEAF_CERTIFICATE, [ + ROOT_CERTIFICATE, + LEAF_CERTIFICATE, + ]), + }, + ]), + ); + return path; +}; + +cliTest( + "keeps unknown block signer identity through CLI and retained MCP views despite a readable lineage", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apk-unknown-block-"); + const path = join(root, "unknown-block.apk"); + await writeFile( + path, + withSigningBlock( + await zipWith([ + { name: "META-INF/CERT.SF", data: "Signature-Version: 1.0" }, + { + name: "META-INF/CERT.EC", + data: signedDataBlock({ + certificates: [LEAF_CERTIFICATE], + signerInfos: [signerInfo({ names: LEAF_CERTIFICATE })], + }), + }, + ]), + [ + { + id: APK_SIGNATURE_V3_ID, + value: v3SchemeValue(null, [LEAF_CERTIFICATE, ROOT_CERTIFICATE]), + }, + ], + ), + ); + const complete = await cli.run({ + arguments: ["inspect-apk-signature", path, "--json"], + }); + expect(complete.exitCode, complete.stderr).toBe(0); + const report = apkSignatureResultSchema.parse( + parseEvidence(complete.json).normalized_result, + ); + expect(report.schemes.v1.signers[0]!.certificate?.sha256_fingerprint).toBe( + LEAF_SHA256, + ); + expect(report.schemes.v3.signers[0]!.certificate).toBeNull(); + expect( + report.schemes.v3.signers[0]!.rotation.lineage[0]!.certificate + ?.sha256_fingerprint, + ).toBe(LEAF_SHA256); + expect(report.posture.v1_signers_match_block_signers).toBeNull(); + const { call } = await connectLocalToolsMcp(); + const reply = await call("inspect_apk_signature", { path }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + const summary = apkSignatureSummaryViewResultSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + expect(summary.summary.posture.v1_signers_match_block_signers).toBeNull(); + expect(summary.summary.source_coverage.signer_identity).toBe("partial"); + await rm(path); + const page = await readPage(call, summary.parent_evidence_id, { + kind: "apk-signature", + collection: "lineage", + }); + expect(page.items).toHaveLength(2); + expect(page.source_coverage.signer_identity).toBe("partial"); + }, +); + +cliTest( + "delivers identical selected signing facts through CLI and SDK with complete historical Evidence", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apk-view-"); + const path = await signedFixture(root), + bundlePath = join(root, "evidence.json"); + const fullCli = await cli.run({ + arguments: ["inspect-apk-signature", path, "--json"], + }); + expect(fullCli.exitCode, fullCli.stderr).toBe(0); + const complete = parseEvidence(fullCli.json), + report = apkSignatureResultSchema.parse(complete.normalized_result); + expect(report.schemes.v3.signers[0]!.signatures[0]).toMatchObject({ + signature_algorithm_id: 0x0103, + signature_bytes: 1, + }); + const selection = { collection: "signers" as const, fields: [], limit: 1 }; + const { call } = await connectLocalToolsMcp(); + const selected = await call("inspect_apk_signature", { + path, + view: selection, + }); + expect(selected.isError, JSON.stringify(selected)).not.toBe(true); + const first = pageFrom(selected.structuredContent); + expect(first.parent_evidence_id).toBe(complete.evidence_id); + expect(first.coverage).toMatchObject({ + total: 2, + next_offset: 1, + exhausted: false, + }); + const cliSelected = await cli.run({ + arguments: [ + "inspect-apk-signature", + path, + "--detail", + "summary", + "--view", + JSON.stringify(selection), + "--evidence_output", + bundlePath, + "--json", + ], + }); + expect(cliSelected.exitCode, cliSelected.stderr).toBe(0); + expect( + pageFrom((cliSelected.json as { summary: unknown }).summary), + ).toEqual(first); + const bundle = JSON.parse(await readFile(bundlePath, "utf8")) as { + records: unknown[]; + }; + const parent = bundle.records + .map(parseEvidence) + .find((x) => x.evidence_id === first.parent_evidence_id); + expect(parent?.normalized_result).toEqual(complete.normalized_result); + await rm(path); + const evidencePath = join(root, "parent.json"); + await writeFile(evidencePath, JSON.stringify(parent)); + const historical = await cli.run({ + arguments: [ + "inspect-analysis-view", + JSON.stringify({ + source: { kind: "evidence-file", path: evidencePath }, + view: { + kind: "apk-signature", + collection: "signers", + ids: [first.items[0]!.id], + fields: [], + }, + }), + "--json", + ], + }); + expect(historical.exitCode, historical.stderr).toBe(0); + expect(pageFrom(historical.json).items).toEqual(first.items); + }, +); + +cliTest( + "composes signer, digest and lineage pages after source deletion without implying verification", + async () => { + const root = await createTestTempDirectory("rea-apk-retained-"); + const path = await signedFixture(root), + { call } = await connectLocalToolsMcp(); + const reply = await call("inspect_apk_signature", { path }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + const summary = apkSignatureSummaryViewResultSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + const parent = summary.parent_evidence_id; + expect(summary.summary.counts).toEqual({ + signers: 2, + digests: 2, + lineage: 2, + }); + expect(summary.summary.source_coverage).toMatchObject({ + authenticity: "unknown", + signature_verification: "not-performed", + v1_entry_coverage: "unknown", + }); + const v3 = summary.summary.signers.find((x) => x.scheme === "v3")!; + await rm(path); + const first = await readPage(call, parent, { + kind: "apk-signature", + collection: "digests", + fields: ["signature_algorithm_id"], + limit: 1, + }); + expect(first.items[0]).toMatchObject({ + signature_algorithm_id: 0x0103, + scheme: "v2", + }); + expect(first.items[0]).not.toHaveProperty("declared_digest_hex"); + expect(first.coverage).toMatchObject({ total: 2, next_offset: 1 }); + const second = await readPage(call, parent, { + kind: "apk-signature", + collection: "digests", + offset: 1, + limit: 1, + fields: ["signature_algorithm_id", "declared_digest_hex"], + }); + const digest = apkDigestRowSchema + .pick({ + id: true, + signer_id: true, + scheme: true, + index: true, + signer_index: true, + signature_algorithm_id: true, + declared_digest_hex: true, + }) + .parse(second.items[0]); + expect(digest.signer_id).toBe(v3.id); + expect(digest.declared_digest_hex).toBe("00".repeat(32)); + expect(second.coverage).toMatchObject({ + total: 2, + next_offset: null, + exhausted: true, + }); + const identities = await readPage(call, parent, { + kind: "apk-signature", + collection: "signers", + ids: summary.summary.signers.map((x) => x.id), + schemes: ["v3"], + fields: [], + }); + expect(identities.items).toEqual([ + { id: v3.id, scheme: "v3", index: 0, certificate_sha256: LEAF_SHA256 }, + ]); + const lineage = await readPage(call, parent, { + kind: "apk-signature", + collection: "lineage", + signer_ids: [v3.id], + fields: ["previous_id", "capabilities"], + }); + expect(lineage.items).toHaveLength(2); + expect(lineage.items[1]).toMatchObject({ + signer_id: v3.id, + previous_id: lineage.items[0]!.id, + certificate_sha256: LEAF_SHA256, + capabilities: { installed_data: true, permission: true }, + }); + const empty = await readPage(call, parent, { + kind: "apk-signature", + collection: "signers", + ids: [], + fields: [], + }); + expect(empty.items).toEqual([]); + expect(empty.coverage).toMatchObject({ total: 0, exhausted: true }); + }, +); + +cliTest( + "keeps unknown v1 identity visible and rejects invalid delivery before reading", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apk-unknown-view-"); + const path = join(root, "malformed-block.apk"); + await writeFile( + path, + await zipWith([ + { name: "META-INF/CERT.SF", data: "Signature-Version: 1.0" }, + { name: "META-INF/CERT.EC", data: Uint8Array.of(0x30, 0) }, + ]), + ); + const { call } = await connectLocalToolsMcp(); + const reply = await call("inspect_apk_signature", { path }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + const summary = apkSignatureSummaryViewResultSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + expect(summary.summary.counts.signers).toBe(1); + expect(summary.summary.signers[0]!.certificate_sha256).toBeNull(); + expect(summary.summary.source_coverage.signer_identity).toBe("partial"); + expect(summary.summary.posture.v1_signers_match_block_signers).toBeNull(); + const badLineage = Buffer.from( + v3SchemeValue(LEAF_CERTIFICATE, [ROOT_CERTIFICATE, LEAF_CERTIFICATE]), + ); + const attribute = badLineage.indexOf(Buffer.from([0x8c, 0x6f, 0xa0, 0x3b])); + expect(attribute).toBeGreaterThan(0); + badLineage.writeUInt32LE(0xffffffff, attribute + 8); + const brokenPath = join(root, "broken-lineage.apk"); + await writeFile( + brokenPath, + withSigningBlock( + await zipWith([{ name: "classes.dex", data: "opaque" }]), + [{ id: APK_SIGNATURE_V3_ID, value: badLineage }], + ), + ); + const broken = await call("inspect_apk_signature", { path: brokenPath }); + expect(broken.isError, JSON.stringify(broken)).not.toBe(true); + const brokenSummary = apkSignatureSummaryViewResultSchema.parse( + parseEvidence(broken.structuredContent).normalized_result, + ); + expect(brokenSummary.summary.posture.rotation_present).toBe(true); + expect(brokenSummary.summary.source_coverage.lineage_decoding).toBe( + "partial", + ); + await rm(brokenPath); + const unknown = await readPage(call, brokenSummary.parent_evidence_id, { + kind: "apk-signature", + collection: "lineage", + }); + expect(unknown.items).toEqual([]); + expect(unknown.source_coverage.lineage_decoding).toBe("partial"); + expect(unknown.unknowns.join(" ")).toContain("lineage node truncated"); + const invalid = await cli.run({ + arguments: [ + "inspect-apk-signature", + join(root, "missing.apk"), + "--detail", + "summary", + "--json", + ], + }); + expect(invalid.exitCode).not.toBe(0); + expect(invalid.stdout).toContain("evidence_output"); + expect(invalid.stdout).not.toContain("ENOENT"); + const ambiguous = await call("inspect_apk_signature", { + path: join(root, "missing.apk"), + detail: "complete", + view: { collection: "signers" }, + }); + expect(ambiguous.isError).toBe(true); + expect(JSON.stringify(ambiguous)).toContain("view"); + expect(JSON.stringify(ambiguous)).not.toContain("ENOENT"); + }, +); diff --git a/tests/boundary/filesystem/apkSignatureViewIdentity.test.ts b/tests/boundary/filesystem/apkSignatureViewIdentity.test.ts new file mode 100644 index 0000000000..0777bc6a76 --- /dev/null +++ b/tests/boundary/filesystem/apkSignatureViewIdentity.test.ts @@ -0,0 +1,54 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { ApkSignatureProvider } from "../../../src/apkSignature/ApkSignatureProvider.js"; +import { ApkSignatureService } from "../../../src/application/apkSignature/ApkSignatureService.js"; +import { freezeJsonSnapshot } from "../../../src/domain/immutableJson.js"; +import { projectApkSignatureView } from "../../../src/domain/analysisView/apkSignatureView.js"; +import { zipWith } from "../../fixtures/apkSignature/v1SignatureBlock.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("checks parent subject identity again when signing facts are already cached", async () => { + const root = await createTestTempDirectory("rea-apk-cache-identity-"); + const path = join(root, "selected.apk"); + await writeFile( + path, + await zipWith([{ name: "classes.dex", data: "opaque" }]), + ); + const service = new ApkSignatureService(new ApkSignatureProvider()); + try { + const result = await service.inspect({ path, detail: "complete" }); + if (!result.ok) throw result.error; + const evidence = result.value; + if (evidence.subject === null) + throw new Error("selected file subject missing"); + const parent = { + evidenceId: evidence.evidence_id, + operation: evidence.operation, + normalizedResult: freezeJsonSnapshot(evidence.normalized_result), + sealedResult: true, + subject: evidence.subject, + limitations: evidence.limitations, + }; + const initial = projectApkSignatureView(parent, { kind: "summary" }); + if (!initial.ok) throw initial.error; + for (const subject of [ + { ...evidence.subject, local_path: join(root, "other.apk") }, + { + ...evidence.subject, + digest: { ...evidence.subject.digest, sha256: "00".repeat(32) }, + }, + ]) { + const invalid = projectApkSignatureView( + { ...parent, subject }, + { kind: "summary" }, + ); + expect(invalid).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + } + } finally { + await service.close(); + } +}); diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 44b8044502..1422d918d9 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -1041,10 +1041,23 @@ it("keeps unrelated test script changes off the SQLite runtime matrix", async () }); }); +it.each([ + "src/apkSignature/ApkSignatureProvider.ts", + "src/domain/apkSignature/apkSignatureSchemes.ts", + "scripts/verify-apk-signature.mjs", +])("selects the real APK signing lane for %s", async (path) => { + const { directory, base } = await fixture(); + const head = await change(directory, [path]); + expect(await classify(directory, base, head)).toMatchObject({ + apk_signature: true, + }); +}); + it.each([ ["browser", "real-browser"], ["binary_layout", "real-binary-layout"], ["evm", "real-evm-interface"], + ["apk_signature", "real-apk-signature"], ["javascript_recovery", "real-javascript-recovery"], ["recorded_crash", "real-recorded-crash"], ["web_network", "real-web-network-captures"], diff --git a/tests/boundary/filesystem/ciRequiredLanes.test.ts b/tests/boundary/filesystem/ciRequiredLanes.test.ts index 7cfd26448d..30fb9e7644 100644 --- a/tests/boundary/filesystem/ciRequiredLanes.test.ts +++ b/tests/boundary/filesystem/ciRequiredLanes.test.ts @@ -319,6 +319,7 @@ it("binds provider build reuse to the current run while keeping standalone dispa "browser", "binary_layout", "evm", + "apk_signature", "javascript_recovery", "recorded_crash", "web_network", diff --git a/tests/fixtures/apkSignature/signingScheme.ts b/tests/fixtures/apkSignature/signingScheme.ts new file mode 100644 index 0000000000..9781911001 --- /dev/null +++ b/tests/fixtures/apkSignature/signingScheme.ts @@ -0,0 +1,22 @@ +/** Full v2/v3 signer framing with opaque, deliberately unverified signatures. */ +const u32 = (value: number): Buffer => { + const bytes = Buffer.alloc(4); + bytes.writeUInt32LE(value); + return bytes; +}; +const lp = (bytes: Uint8Array): Buffer => + Buffer.concat([u32(bytes.length), bytes]); + +export const apkSchemeValue = ( + signedData: Uint8Array, + sdkRange?: { readonly min: number; readonly max: number }, +): Uint8Array => { + const signature = Buffer.concat([u32(0x0103), lp(Uint8Array.of(1))]); + const signer = Buffer.concat([ + lp(signedData), + ...(sdkRange === undefined ? [] : [u32(sdkRange.min), u32(sdkRange.max)]), + lp(lp(signature)), + lp(new Uint8Array()), + ]); + return lp(lp(signer)); +}; diff --git a/tests/fixtures/apkSignature/v1SignatureBlock.ts b/tests/fixtures/apkSignature/v1SignatureBlock.ts new file mode 100644 index 0000000000..a59147e8ab --- /dev/null +++ b/tests/fixtures/apkSignature/v1SignatureBlock.ts @@ -0,0 +1,358 @@ +/** + * v1 (JAR) signature block fixtures. The two CMS blocks were produced by + * `openssl cms -sign -binary -noattr [-keyid] -md sha256` over a one-line .SF + * with an EC P-256 signer certificate issued by a separate root, and carry + * [leaf, root] in that order. The DER builders below assemble the shapes a + * producer may emit but OpenSSL does not (reordered or missing certificates, + * several SignerInfos, BER indefinite lengths, malformed fields). + */ +import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { apkSchemeValue } from "./signingScheme.js"; + +const fromBase64 = (text: string): Uint8Array => + Uint8Array.from(Buffer.from(text, "base64")); + +/** CN=REA Test Signer,O=REA,C=PT; serial 0x1234; issued by the root below. */ +export const LEAF_CERTIFICATE = fromBase64( + "MIIBjzCCATWgAwIBAgICEjQwCgYIKoZIzj0EAwIwJjEWMBQGA1UEAwwNUkVBIFRlc3QgUm9vdDEMMAoGA1UECgwDUkVBMCAXDTI2MTAxNzEyMTMxOFoYDzIxMjYwOTIzMTIxMzE4WjA1MRgwFgYDVQQDDA9SRUEgVGVzdCBTaWduZXIxDDAKBgNVBAoMA1JFQTELMAkGA1UEBhMCUFQwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATGFItDQ8IkJ0NGd9sM72grl0tW7m+86cN02S7okjsg7Pk0YLJ3bD6QRDBRxfE97ykcOik6ytLtIcj+rkJE1Joro0IwQDAdBgNVHQ4EFgQU7u5MpCXpdQ16uVQpq8x8ZqEIafgwHwYDVR0jBBgwFoAUu6si+isK/nuIGf2i6Fggd7gEabkwCgYIKoZIzj0EAwIDSAAwRQIgSviQ7GDMVMTJ/5rns7i7mhLqqj/pTF+t41X0QYr/qacCIQDAuFe4to58G0aLLg/ajUWsnS0TtzbV1vCF82wIbcTnaQ==", +); +export const LEAF_SHA256 = + "9224e30fd7981ebeb903d2593075845316d5c4f546c2f3e4675c03ef3302ef88"; +export const LEAF_SUBJECT = "CN=REA Test Signer\nO=REA\nC=PT"; + +/** CN=REA Test Root,O=REA; self-signed, serial 1. */ +export const ROOT_CERTIFICATE = fromBase64( + "MIIBkDCCATagAwIBAgIBATAKBggqhkjOPQQDAjAmMRYwFAYDVQQDDA1SRUEgVGVzdCBSb290MQwwCgYDVQQKDANSRUEwIBcNMjYxMDE3MTIxMzE4WhgPMjEyNjA5MjMxMjEzMThaMCYxFjAUBgNVBAMMDVJFQSBUZXN0IFJvb3QxDDAKBgNVBAoMA1JFQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABP1ZSF3TjoEuKOPudKO/iJl6/YDqTqW8rYc8JNsxwSDcWnvOmuEdX/usLAZ2vI9MxDGrg8mX/LpPRJwSxMkA1lijUzBRMB0GA1UdDgQWBBS7qyL6Kwr+e4gZ/aLoWCB3uARpuTAfBgNVHSMEGDAWgBS7qyL6Kwr+e4gZ/aLoWCB3uARpuTAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gAMEUCIEzOCpuf6I5Tm9O/v+/KYBVW6t6F7/s0vMVBtpbd9XUvAiEAiAfaZ1xLTu+iS2G8RzZBm+4CwHBai9Vss4Yj/Ppb574=", +); +export const ROOT_SHA256 = + "b8470a4468a232423983c0b4b0414c43af60ae66ea167fdd05a06a3511ebb04d"; + +/** OpenSSL CMS, SignerInfo identified by issuerAndSerialNumber. */ +export const OPENSSL_BLOCK_ISSUER_SERIAL = fromBase64( + "MIID9QYJKoZIhvcNAQcCoIID5jCCA+ICAQExDTALBglghkgBZQMEAgEwCwYJKoZIhvcNAQcBoIIDJzCCAY8wggE1oAMCAQICAhI0MAoGCCqGSM49BAMCMCYxFjAUBgNVBAMMDVJFQSBUZXN0IFJvb3QxDDAKBgNVBAoMA1JFQTAgFw0yNjEwMTcxMjEzMThaGA8yMTI2MDkyMzEyMTMxOFowNTEYMBYGA1UEAwwPUkVBIFRlc3QgU2lnbmVyMQwwCgYDVQQKDANSRUExCzAJBgNVBAYTAlBUMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExhSLQ0PCJCdDRnfbDO9oK5dLVu5vvOnDdNku6JI7IOz5NGCyd2w+kEQwUcXxPe8pHDopOsrS7SHI/q5CRNSaK6NCMEAwHQYDVR0OBBYEFO7uTKQl6XUNerlUKavMfGahCGn4MB8GA1UdIwQYMBaAFLurIvorCv57iBn9ouhYIHe4BGm5MAoGCCqGSM49BAMCA0gAMEUCIEr4kOxgzFTEyf+a57O4u5oS6qo/6UxfreNV9EGK/6mnAiEAwLhXuLaOfBtGiy4P2o1FrJ0tE7c21dbwhfNsCG3E52kwggGQMIIBNqADAgECAgEBMAoGCCqGSM49BAMCMCYxFjAUBgNVBAMMDVJFQSBUZXN0IFJvb3QxDDAKBgNVBAoMA1JFQTAgFw0yNjEwMTcxMjEzMThaGA8yMTI2MDkyMzEyMTMxOFowJjEWMBQGA1UEAwwNUkVBIFRlc3QgUm9vdDEMMAoGA1UECgwDUkVBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE/VlIXdOOgS4o4+50o7+ImXr9gOpOpbythzwk2zHBINxae86a4R1f+6wsBna8j0zEMauDyZf8uk9EnBLEyQDWWKNTMFEwHQYDVR0OBBYEFLurIvorCv57iBn9ouhYIHe4BGm5MB8GA1UdIwQYMBaAFLurIvorCv57iBn9ouhYIHe4BGm5MA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDSAAwRQIgTM4Km5/ojlOb07+/78pgFVbq3oXv+zS8xUG2lt31dS8CIQCIB9pnXEtO76JLYbxHNkGb7gLAcFqL1WyzhiP8+lvnvjGBlTCBkgIBATAsMCYxFjAUBgNVBAMMDVJFQSBUZXN0IFJvb3QxDDAKBgNVBAoMA1JFQQICEjQwCwYJYIZIAWUDBAIBMAoGCCqGSM49BAMCBEYwRAIgdE1HcVYt2jYd5ZhkrHhcZ5V+qfuO9SwdT0gWz1MXpdICIEMkyi87x+4KIT0Vc5XX9k0IHhnwIANPw4Lcx+0owlAg", +); + +/** OpenSSL CMS (`-keyid`), SignerInfo identified by subjectKeyIdentifier. */ +export const OPENSSL_BLOCK_KEY_ID = fromBase64( + "MIID3AYJKoZIhvcNAQcCoIIDzTCCA8kCAQMxDTALBglghkgBZQMEAgEwCwYJKoZIhvcNAQcBoIIDJzCCAY8wggE1oAMCAQICAhI0MAoGCCqGSM49BAMCMCYxFjAUBgNVBAMMDVJFQSBUZXN0IFJvb3QxDDAKBgNVBAoMA1JFQTAgFw0yNjEwMTcxMjEzMThaGA8yMTI2MDkyMzEyMTMxOFowNTEYMBYGA1UEAwwPUkVBIFRlc3QgU2lnbmVyMQwwCgYDVQQKDANSRUExCzAJBgNVBAYTAlBUMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExhSLQ0PCJCdDRnfbDO9oK5dLVu5vvOnDdNku6JI7IOz5NGCyd2w+kEQwUcXxPe8pHDopOsrS7SHI/q5CRNSaK6NCMEAwHQYDVR0OBBYEFO7uTKQl6XUNerlUKavMfGahCGn4MB8GA1UdIwQYMBaAFLurIvorCv57iBn9ouhYIHe4BGm5MAoGCCqGSM49BAMCA0gAMEUCIEr4kOxgzFTEyf+a57O4u5oS6qo/6UxfreNV9EGK/6mnAiEAwLhXuLaOfBtGiy4P2o1FrJ0tE7c21dbwhfNsCG3E52kwggGQMIIBNqADAgECAgEBMAoGCCqGSM49BAMCMCYxFjAUBgNVBAMMDVJFQSBUZXN0IFJvb3QxDDAKBgNVBAoMA1JFQTAgFw0yNjEwMTcxMjEzMThaGA8yMTI2MDkyMzEyMTMxOFowJjEWMBQGA1UEAwwNUkVBIFRlc3QgUm9vdDEMMAoGA1UECgwDUkVBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE/VlIXdOOgS4o4+50o7+ImXr9gOpOpbythzwk2zHBINxae86a4R1f+6wsBna8j0zEMauDyZf8uk9EnBLEyQDWWKNTMFEwHQYDVR0OBBYEFLurIvorCv57iBn9ouhYIHe4BGm5MB8GA1UdIwQYMBaAFLurIvorCv57iBn9ouhYIHe4BGm5MA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDSAAwRQIgTM4Km5/ojlOb07+/78pgFVbq3oXv+zS8xUG2lt31dS8CIQCIB9pnXEtO76JLYbxHNkGb7gLAcFqL1WyzhiP8+lvnvjF9MHsCAQOAFO7uTKQl6XUNerlUKavMfGahCGn4MAsGCWCGSAFlAwQCATAKBggqhkjOPQQDAgRHMEUCICTKaKt+DjSOz1TMGWDildMG7y6j/YxpuhVihbL2dqlLAiEA/lt/MfWWelivz5TMG0ylVpYcIdk7GF3PXoO7eq0dEus=", +); + +export const OID_SHA256 = "2.16.840.1.101.3.4.2.1"; +export const OID_ECDSA_SHA256 = "1.2.840.10045.4.3.2"; + +/** DER length octets (definite form). */ +const lengthOctets = (length: number): number[] => { + if (length < 0x80) return [length]; + const out: number[] = []; + for (let v = length; v > 0; v = Math.floor(v / 256)) out.unshift(v & 0xff); + return [0x80 | out.length, ...out]; +}; + +export const tlv = ( + tag: number, + ...content: readonly Uint8Array[] +): Uint8Array => { + const body = Buffer.concat(content); + return Uint8Array.from([tag, ...lengthOctets(body.length), ...body]); +}; +/** A constructed element in BER indefinite-length form. */ +export const indefinite = ( + tag: number, + ...content: readonly Uint8Array[] +): Uint8Array => + Uint8Array.from( + Buffer.concat([Buffer.from([tag, 0x80]), ...content, Buffer.from([0, 0])]), + ); + +const encodeOid = (dotted: string): Uint8Array => { + const [a = 0, b = 0, ...rest] = dotted.split(".").map(Number); + const out = [a * 40 + b]; + for (const arc of rest) { + const bytes = [arc & 0x7f]; + for (let v = Math.floor(arc / 128); v > 0; v = Math.floor(v / 128)) + bytes.unshift(0x80 | (v & 0x7f)); + out.push(...bytes); + } + return tlv(0x06, Uint8Array.from(out)); +}; +export const oid = encodeOid; +const integer = (value: number): Uint8Array => + tlv(0x02, Uint8Array.from([value])); +const algorithm = (dotted: string): Uint8Array => tlv(0x30, oid(dotted)); + +interface DerSpan { + readonly start: number; + readonly content: number; + readonly end: number; +} + +/** Definite-length children of `bytes[start, end)`; fixture certificates only. */ +const derChildren = ( + bytes: Uint8Array, + start: number, + end: number, +): DerSpan[] => { + const out: DerSpan[] = []; + for (let at = start; at < end;) { + const first = bytes[at + 1] ?? 0; + const count = first > 0x80 ? first & 0x7f : 0; + let length = first > 0x80 ? 0 : first; + for (let i = 0; i < count; i += 1) + length = length * 256 + (bytes[at + 2 + i] ?? 0); + const content = at + 2 + count; + out.push({ start: at, content, end: content + length }); + at = content + length; + } + return out; +}; + +/** The v3 tbsCertificate fields of a fixture certificate, and its outer parts. */ +const certificateParts = (certificate: Uint8Array) => { + const [cert] = derChildren(certificate, 0, certificate.length); + const [tbs, ...outer] = derChildren(certificate, cert!.content, cert!.end); + return { + fields: derChildren(certificate, tbs!.content, tbs!.end), + outer, + }; +}; + +/** tbsCertificate fields a SignerInfo names: issuer Name TLV and serial content. */ +const issuerAndSerial = (certificate: Uint8Array): Uint8Array => { + // Fixture certificates are v3 with short-form serials: walk the known layout. + const { fields } = certificateParts(certificate); + const serial = fields[1]!; + const issuer = fields[3]!; + return tlv( + 0x30, + certificate.subarray(issuer.start, issuer.end), + certificate.subarray(serial.start, serial.end), + ); +}; + +/** A Name of `count` OU attributes, each a UTF8String of `value`. */ +export const organizationalUnits = (count: number, value: string): Uint8Array => + tlv( + 0x30, + ...Array.from({ length: count }, () => + tlv( + 0x31, + tlv(0x30, oid("2.5.4.11"), tlv(0x0c, new TextEncoder().encode(value))), + ), + ), + ); + +/** + * `certificate` with its subject replaced. The signature no longer matches, + * which nothing here verifies; issuer and serial are kept so a SignerInfo + * still names it. + */ +export const withSubject = ( + certificate: Uint8Array, + subject: Uint8Array, +): Uint8Array => { + const { fields, outer } = certificateParts(certificate); + const part = ({ start, end }: DerSpan) => certificate.subarray(start, end); + return tlv( + 0x30, + tlv( + 0x30, + ...fields.map((field, index) => (index === 5 ? subject : part(field))), + ), + ...outer.map(part), + ); +}; + +export interface SignerInfoSpec { + /** Certificate whose issuer and serial the SignerInfo names. */ + readonly names: Uint8Array; + /** Replace the required signature TLV, including omission for malformed fixtures. */ + readonly signature?: Uint8Array; +} + +export const signerInfo = ({ names, signature }: SignerInfoSpec): Uint8Array => + tlv( + 0x30, + integer(1), + issuerAndSerial(names), + algorithm(OID_SHA256), + algorithm(OID_ECDSA_SHA256), + signature ?? tlv(0x04, Uint8Array.from([1, 2, 3])), + ); + +/** ContentInfo { signedData, [0] SignedData { … certificates, signerInfos } }. */ +export const signedDataBlock = (options: { + readonly certificates: readonly Uint8Array[]; + readonly signerInfos: readonly Uint8Array[]; + readonly contentType?: string; +}): Uint8Array => + tlv( + 0x30, + oid(options.contentType ?? "1.2.840.113549.1.7.2"), + tlv( + 0xa0, + tlv( + 0x30, + integer(1), + tlv(0x31, algorithm(OID_SHA256)), + tlv(0x30, oid("1.2.840.113549.1.7.1")), + tlv(0xa0, ...options.certificates), + tlv(0x31, ...options.signerInfos), + ), + ), + ); + +/** + * A v1-signed archive: each entry stored or deflated as requested. Local + * headers defer their sizes to a data descriptor unless `dataDescriptor` is + * false, as apksigner writes them. + */ +export const zipWith = async ( + entries: readonly { + readonly name: string; + readonly data: Uint8Array | string; + readonly stored?: boolean; + }[], + options: { readonly dataDescriptor?: boolean } = {}, +): Promise => { + const writer = new ZipWriter(new Uint8ArrayWriter(), { + dataDescriptor: options.dataDescriptor ?? true, + }); + for (const entry of entries) + await writer.add( + entry.name, + new Uint8ArrayReader( + typeof entry.data === "string" + ? new TextEncoder().encode(entry.data) + : entry.data, + ), + { level: entry.stored === true ? 0 : 6 }, + ); + return writer.close(); +}; + +const u32 = (v: number): number[] => [ + v & 0xff, + (v >> 8) & 0xff, + (v >> 16) & 0xff, + (v >>> 24) & 0xff, +]; +const u64 = (v: number): number[] => [...u32(v), ...u32(0)]; +const lp = (b: Uint8Array): Buffer => + Buffer.concat([Buffer.from(u32(b.length)), b]); + +/** An APK Signature Scheme v2 value with one signer carrying `certificate`. */ +export const v2SchemeValue = (certificate: Uint8Array): Uint8Array => { + const digest = lp( + Buffer.concat([Buffer.from(u32(0x0103)), lp(new Uint8Array(32))]), + ); + const signedData = Buffer.concat([ + lp(digest), + lp(lp(certificate)), + lp(new Uint8Array(0)), + ]); + return apkSchemeValue(signedData); +}; + +/** Insert an APK Signing Block before the central directory of a comment-free ZIP. */ +export const withSigningBlock = ( + zip: Uint8Array, + pairs: readonly { readonly id: number; readonly value: Uint8Array }[], +): Uint8Array => { + const bytes = Buffer.from(zip); + const eocd = bytes.length - 22; + if (bytes.readUInt32LE(eocd) !== 0x06054b50) + throw new Error("fixture ZIP must end with a comment-free EOCD"); + const cdOffset = bytes.readUInt32LE(eocd + 16); + const framed = Buffer.concat( + pairs.flatMap((p) => [ + Buffer.from([...u64(4 + p.value.length), ...u32(p.id)]), + Buffer.from(p.value), + ]), + ); + const size = framed.length + 8 + 16; + const block = Buffer.concat([ + Buffer.from(u64(size)), + framed, + Buffer.from(u64(size)), + Buffer.from("APK Sig Block 42", "latin1"), + ]); + const tail = Buffer.from(bytes.subarray(cdOffset)); + tail.writeUInt32LE(cdOffset + block.length, tail.length - 22 + 16); + return Uint8Array.from( + Buffer.concat([bytes.subarray(0, cdOffset), block, tail]), + ); +}; + +export const APK_SIGNATURE_V2_ID = 0x7109871a; + +export const APK_SIGNATURE_V3_ID = 0xf05368c0; +const PROOF_OF_ROTATION_ATTR_ID = 0x3ba06f8c; + +/** + * An APK Signature Scheme v3 value with one signer carrying `certificate` and, + * when given, a proof-of-rotation lineage over `lineage` (oldest first, every + * capability retained). Null declares an empty certificate sequence rather + * than a certificate containing zero bytes. Node signatures are empty: + * nothing here is verified. + */ +export const v3SchemeValue = ( + certificate: Uint8Array | null, + lineage?: readonly Uint8Array[], +): Uint8Array => { + const digest = lp( + Buffer.concat([Buffer.from(u32(0x0103)), lp(new Uint8Array(32))]), + ); + const node = (cert: Uint8Array): Buffer => + lp( + Buffer.concat([ + lp(Buffer.concat([lp(cert), Buffer.from(u32(0))])), + Buffer.from(u32(0x17)), + Buffer.from(u32(0)), + lp(new Uint8Array(0)), + ]), + ); + const attributes = + lineage === undefined + ? [] + : [ + lp( + Buffer.concat([ + Buffer.from(u32(PROOF_OF_ROTATION_ATTR_ID)), + Buffer.from(u32(1)), + ...lineage.map(node), + ]), + ), + ]; + const signedData = Buffer.concat([ + lp(digest), + lp(certificate === null ? new Uint8Array() : lp(certificate)), + Buffer.from(u32(28)), + Buffer.from(u32(0x7fffffff)), + lp(Buffer.concat(attributes)), + ]); + return apkSchemeValue(signedData, { min: 28, max: 0x7fffffff }); +}; + +/** The central-directory record and local header offsets of the entry `name`. */ +export const zipEntryOffsets = ( + zip: Uint8Array, + name: string, +): { readonly central: number; readonly local: number } => { + const bytes = Buffer.from(zip.buffer, zip.byteOffset, zip.byteLength); + const encoded = Buffer.from(name); + for ( + let at = bytes.readUInt32LE(bytes.length - 22 + 16); + bytes.readUInt32LE(at) === 0x02014b50; + at += + 46 + + bytes.readUInt16LE(at + 28) + + bytes.readUInt16LE(at + 30) + + bytes.readUInt16LE(at + 32) + ) + if ( + bytes.readUInt16LE(at + 28) === encoded.length && + bytes.subarray(at + 46, at + 46 + encoded.length).equals(encoded) + ) + return { central: at, local: bytes.readUInt32LE(at + 42) }; + throw new Error(`fixture ZIP has no entry ${name}`); +}; diff --git a/tests/process-global/apkSignatureLifecycle.test.ts b/tests/process-global/apkSignatureLifecycle.test.ts new file mode 100644 index 0000000000..63d074e2a1 --- /dev/null +++ b/tests/process-global/apkSignatureLifecycle.test.ts @@ -0,0 +1,192 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { afterAll, afterEach, beforeAll, expect, it } from "vitest"; +import { vi } from "vitest"; + +const fault = vi.hoisted(() => ({ + path: "", + failClose: "never" as "never" | "once" | "always", + abortAtStat: undefined as + | { controller: AbortController; reason: unknown } + | undefined, + pauseAtStat: undefined as + | { started: () => void; resume: Promise } + | undefined, +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const fs = await importOriginal(); + return { + ...fs, + open: async (...args: Parameters) => { + const handle = await fs.open(...args); + if (args[0] !== fault.path) return handle; + const close = handle.close.bind(handle); + const stat = handle.stat.bind(handle); + handle.stat = (async (...statArgs: Parameters) => { + const abort = fault.abortAtStat; + fault.abortAtStat = undefined; + abort?.controller.abort(abort.reason); + const pause = fault.pauseAtStat; + fault.pauseAtStat = undefined; + if (pause !== undefined) { + pause.started(); + await pause.resume; + } + return stat(...statArgs); + }) as typeof handle.stat; + handle.close = async () => { + if (fault.failClose !== "never") { + if (fault.failClose === "once") fault.failClose = "never"; + throw new Error("controlled descriptor close failure"); + } + return close(); + }; + return handle; + }, + }; +}); + +import { ApkSignatureProvider } from "../../src/apkSignature/ApkSignatureProvider.js"; +import { createCli } from "../../src/cli.js"; +import { CLI_COMMANDS } from "../../src/cliCommandNames.js"; +import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; + +let root = ""; +let path = ""; +const exitCode = process.exitCode; +const deferred = () => { + let complete = () => {}; + const promise = new Promise((resolve) => { + complete = resolve; + }); + return { promise, resolve: () => complete() }; +}; +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), "rea-apk-lifecycle-")); + path = join(root, "unsigned.apk"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("classes.dex", new TextReader("x")); + await writeFile(path, await writer.close()); +}); +afterEach(() => { + fault.path = ""; + fault.failClose = "never"; + fault.abortAtStat = undefined; + fault.pauseAtStat = undefined; + process.exitCode = exitCode; +}); +afterAll(async () => { + await rm(root, { recursive: true, force: true }); +}); + +it("serializes reads across providers, preserves queued aborts and drains admitted work on close", async () => { + const first = new ApkSignatureProvider(); + const second = new ApkSignatureProvider(); + const started = deferred(); + const resume = deferred(); + fault.path = path; + fault.pauseAtStat = { + started: () => started.resolve(), + resume: resume.promise, + }; + const reading = first.inspect({ path }); + await started.promise; + const queued = second.inspect({ path }); + const controller = new AbortController(); + const reason = new Error("cancel while queued"); + const cancelled = second.inspect({ path }, { signal: controller.signal }); + let closed = false; + const closing = second.close().then(() => { + closed = true; + }); + try { + controller.abort(reason); + expect(await cancelled).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError", cause: reason }, + }); + expect(closed).toBe(false); + resume.resolve(); + expect((await reading).ok).toBe(true); + expect((await queued).ok).toBe(true); + await closing; + expect(closed).toBe(true); + } finally { + resume.resolve(); + await Promise.all([reading, queued, cancelled, closing]); + await first.close(); + await second.close(); + } +}); + +it("keeps the abort reason itself as the cancellation cause", async () => { + const provider = new ApkSignatureProvider(); + const controller = new AbortController(); + const reason = new Error("caller abort reason"); + fault.path = path; + fault.abortAtStat = { controller, reason }; + try { + const result = await provider.inspect( + { path }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(reason); + } finally { + await provider.close(); + } +}); + +it("reports a completed read whose close failed, and a closed scope, as execution failures", async () => { + const provider = new ApkSignatureProvider(); + fault.path = path; + fault.failClose = "once"; + const first = await provider.inspect({ path }); + expect(first.ok).toBe(false); + if (first.ok) return; + expect(projectAnalysisError(first.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { + diagnostics: { primary_error: { code: "execution_failure" } }, + }, + }); + expect(first.error.cause).toBeInstanceOf(AggregateError); + if (!(first.error.cause instanceof AggregateError)) return; + expect(first.error.cause.errors[1]).toMatchObject({ + cleanupResources: [path], + }); + + await provider.close(); + expect(first.error.cleanupIncomplete).toBe(false); + const closed = await provider.inspect({ path }); + expect(closed.ok).toBe(false); + if (!closed.ok) + expect(projectAnalysisError(closed.error).code).toBe("execution_failure"); +}); + +it("projects a provider close that still fails through the public CLI", async () => { + fault.path = path; + fault.failClose = "always"; + let stdout = ""; + await createCli({}).serve( + [CLI_COMMANDS.inspectApkSignature, path, "--json"], + { + env: {}, + stdout: (text) => { + stdout += text; + }, + exit: () => undefined, + }, + ); + fault.failClose = "never"; + expect(JSON.parse(stdout)).toMatchObject({ + code: "cleanup_incomplete", + details: { cleanup: "incomplete", resources: [path] }, + }); + expect(process.exitCode).toBe(1); +});