[GH-ISSUE #6821] [Bug] APK signer metadata is read from PEM text inside the carried DER certificate #4167

Closed
opened 2026-10-31 00:47:21 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 23, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/6821

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Native analysis or decompilation

Observed behavior

APK signer inspection combines one certificate's fingerprint with another certificate's subject, issuer and serial when the carried DER certificate contains PEM certificate text in an extension.

The carried certificate has subject/issuer CN=REA Outer Metadata and serial 04D2. Its noncritical extension contains PEM text for the public root-certificate fixture. REA returns the carried certificate's correct SHA-256 fingerprint but subject/issuer CN=REA Test Root\nO=REA and serial 01, taken from that embedded text. The ordinary-text extension control returns the correct facts.

This reproduced through the production provider in 3/3 v2 and 2/2 v1 inspections. The v1 signer is resolved and has decode_error: null. This is a static metadata correctness report, not a signature verification or trust claim.

Reproduction

The shortest executed reproduction exercises the shared production certificate reader. Save the following outside the source checkout as probe.mts; from the checkout run node_modules/.bin/tsx /path/to/probe.mts "$PWD". It uses existing public test fixtures and generates an ephemeral signing key in memory; it writes no files.

import { X509Certificate, generateKeyPairSync, sign } from 'node:crypto';
import { pathToFileURL } from 'node:url';
const repo = process.argv[2];
const { certificateFacts } = await import(pathToFileURL(`${repo}/src/apkSignature/apkCertificateFacts.ts`).href);
const { LEAF_CERTIFICATE, ROOT_CERTIFICATE, tlv, oid } = await import(pathToFileURL(`${repo}/tests/fixtures/apkSignature/v1SignatureBlock.ts`).href);
const pem = (bytes: Uint8Array) => '-----BEGIN CERTIFICATE-----\n' + Buffer.from(bytes).toString('base64').match(/.{1,64}/g)!.join('\n') + '\n-----END CERTIFICATE-----\n';
function children(bytes: Uint8Array, start = 0, end = bytes.length) {
  const result: {start:number, content:number, end:number}[] = [];
  for (let at = start; at < end;) {
    const first = bytes[at + 1]!;
    const count = first < 128 ? 0 : first & 127;
    let length = count ? 0 : first;
    for (let i = 0; i < count; i++) length = length * 256 + bytes[at + 2 + i]!;
    const content = at + 2 + count;
    result.push({ start:at, content, end:content + length });
    at = content + length;
  }
  return result;
}
const [whole] = children(LEAF_CERTIFICATE);
const [originalTbs] = children(LEAF_CERTIFICATE, whole!.content, whole!.end);
const fields = children(LEAF_CERTIFICATE, originalTbs!.content, originalTbs!.end).map(s => LEAF_CERTIFICATE.subarray(s.start, s.end));
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve:'prime256v1' });
const spki = publicKey.export({ type:'spki', format:'der' });
const name = tlv(0x30, tlv(0x31, tlv(0x30, oid('2.5.4.3'), tlv(0x0c, Buffer.from('REA Outer Metadata')))));
for (const [label, content] of [['control', 'ordinary extension text'], ['embedded-pem', '\n' + pem(ROOT_CERTIFICATE) + '\n']]) {
  const extension = tlv(0x30, oid('1.3.6.1.4.1.55555.1'), tlv(0x04, tlv(0x0c, Buffer.from(content))));
  const tbs = tlv(0x30, fields[0]!, tlv(0x02, Uint8Array.of(0x04,0xd2)), fields[2]!, name, fields[4]!, name, spki, tlv(0xa3, tlv(0x30, extension)));
  const cert = tlv(0x30, tbs, fields[2]!, tlv(0x03, Uint8Array.of(0), sign('sha256', tbs, privateKey)));
  const outer = new X509Certificate(pem(cert));
  const actual = certificateFacts(cert);
  console.log(JSON.stringify({ label, selfSignatureValid:outer.verify(publicKey), referenceRawMatchesInput:Buffer.from(outer.raw).equals(cert), expected:{ subject:outer.subject, issuer:outer.issuer, serial:outer.serialNumber }, actual:{ subject:actual.subject, issuer:actual.issuer, serial:actual.serial_number }, fingerprintStillMatches:actual.sha256_fingerprint === outer.fingerprint256.replaceAll(':','').toLowerCase() }));
}

The probe ran once per control/affected input before this draft. The separate retained provider probe wraps the same certificate construction in v1 and v2 signing records; provider observations above come from that probe and its stored-artifact recheck, not from the reduced reader call alone.

Expected behavior

Subject, issuer, serial and fingerprint should all describe the exact carried DER certificate. Extension text must remain extension data. The REA certificate contract ties readable X.509 facts to the embedded DER; RFC 5280 section 4.2 defines extension values and permits unknown noncritical extensions.

Independent checks read the expected outer identity with openssl x509 -inform DER. The certificate's self-signature verifies, and the explicit-PEM wrapper used as the reduced probe's reference returns raw bytes identical to the input DER.

Impact and scope

A bounded set of signer certificates receives misleading display metadata while its fingerprint remains correct. Both tested signing schemes expose the mismatch; certificate clustering by subject/issuer/serial can therefore disagree with fingerprint identity. No real APK corpus was measured. v3, rotation and source-stamp callers share this reader by source inspection, but were not exercised.

Explicit DER parsing recovers the correct identity from the retained certificate. A complete user-facing APK certificate extraction workaround was not evaluated.

REA version

6.7.0, source e2efd10b70a2b5f15a7ac1b5a457a9a1dbcab4d4.

Runtime and platform

Node.js v24.22.1, Linux 7.2.8-9-omarchy x86_64. Independent reference: OpenSSL 3.6.5. No Android SDK, device or app execution.

Target and artifact context

Benign synthetic EC P-256 certificates, with an ordinary private noncritical extension, placed in local v1/v2 APK-shaped ZIP fixtures. v2 signed-data signatures and content digests match; detached v1 CMS signatures verify. The ZIP's DEX is a placeholder: Android installability and apksigner verification were not tested.

Interface

Other

Production provider API and its shared certificate reader. CLI/MCP transports were not exercised.

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Reduced reader output, with matching fingerprint and valid self-signature in both cases:

control:
  expected/actual subject = CN=REA Outer Metadata
  expected/actual issuer  = CN=REA Outer Metadata
  expected/actual serial  = 04D2
embedded-pem:
  expected subject = CN=REA Outer Metadata
  actual subject   = CN=REA Test Root\nO=REA
  expected issuer  = CN=REA Outer Metadata
  actual issuer    = CN=REA Test Root\nO=REA
  expected serial  = 04D2
  actual serial    = 01

The stored provider fixture reports outer fingerprint e9a2b1a8f22a7ed5ad22f9eb1e7d7f4c88cfbe389a16ed08cbd43fc1cf677a6c; the unrelated embedded certificate's fingerprint is b8470a4468a232423983c0b4b0414c43af60ae66ea167fdd05a06a3511ebb04d. Fresh key generation changes the outer certificate fingerprint.

  • Evidence provenance: observed production-provider and shared-reader results, with independent certificate/signature checks.
  • Local verification: reproduced on pinned Node; OpenSSL explicit DER reads the outer subject, issuer and serial, and verifies the stored CMS signatures.
  • Reproduction completeness: complete reduced reader reproduction; full v1/v2 provider fixtures and outputs retained. No CLI/MCP or Android-installation claim.
  • Shared reader.
  • v1 propagation.
  • Block-scheme projection.

Typed outcome or recovery expected

Return facts for the carried DER certificate together with its fingerprint. If those exact bytes cannot be parsed, preserve unknown facts instead of borrowing a nested certificate's metadata.

Analysis or suspected cause

Source-derived cause supported by the probe: certificateFacts passes DER bytes to Node's auto-detecting X509Certificate constructor and separately hashes the original DER. Node tries PEM before DER, so PEM inside the extension supplies the parsed facts while the fingerprint still identifies the outer certificate.

Additional context

Open/closed searches covered "apk" "signature", "signer", "certificate" "APK", "certificate" "PEM" and "APK" "embedded". No matching report found. Related but distinct: #6439 concerns issuer DirectoryString matching; #6440 concerns empty-v2 fallback; closed #6603 concerns constructed BER signature values. No known-good REA version was established.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: Production v1/v2 provider results deterministically attribute subject, issuer and serial to a different certificate for these valid DER inputs; the correct raw fingerprint survives and no general inspection failure occurs.
  • Workaround status: unverified
  • Workaround basis: Explicit DER parsing recovered the expected metadata from the stored certificate, but a complete practical APK extraction workflow for affected users was not tested.
Originally created by @coygeek on GitHub (Oct 23, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6821 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Native analysis or decompilation ### Observed behavior APK signer inspection combines one certificate's fingerprint with another certificate's subject, issuer and serial when the carried DER certificate contains PEM certificate text in an extension. The carried certificate has subject/issuer `CN=REA Outer Metadata` and serial `04D2`. Its noncritical extension contains PEM text for the public root-certificate fixture. REA returns the carried certificate's correct SHA-256 fingerprint but subject/issuer `CN=REA Test Root\nO=REA` and serial `01`, taken from that embedded text. The ordinary-text extension control returns the correct facts. This reproduced through the production provider in 3/3 v2 and 2/2 v1 inspections. The v1 signer is resolved and has `decode_error: null`. This is a static metadata correctness report, not a signature verification or trust claim. ### Reproduction The shortest executed reproduction exercises the shared production certificate reader. Save the following outside the source checkout as `probe.mts`; from the checkout run `node_modules/.bin/tsx /path/to/probe.mts "$PWD"`. It uses existing public test fixtures and generates an ephemeral signing key in memory; it writes no files. ```ts import { X509Certificate, generateKeyPairSync, sign } from 'node:crypto'; import { pathToFileURL } from 'node:url'; const repo = process.argv[2]; const { certificateFacts } = await import(pathToFileURL(`${repo}/src/apkSignature/apkCertificateFacts.ts`).href); const { LEAF_CERTIFICATE, ROOT_CERTIFICATE, tlv, oid } = await import(pathToFileURL(`${repo}/tests/fixtures/apkSignature/v1SignatureBlock.ts`).href); const pem = (bytes: Uint8Array) => '-----BEGIN CERTIFICATE-----\n' + Buffer.from(bytes).toString('base64').match(/.{1,64}/g)!.join('\n') + '\n-----END CERTIFICATE-----\n'; function children(bytes: Uint8Array, start = 0, end = bytes.length) { const result: {start:number, content:number, end:number}[] = []; for (let at = start; at < end;) { const first = bytes[at + 1]!; const count = first < 128 ? 0 : first & 127; let length = count ? 0 : first; for (let i = 0; i < count; i++) length = length * 256 + bytes[at + 2 + i]!; const content = at + 2 + count; result.push({ start:at, content, end:content + length }); at = content + length; } return result; } const [whole] = children(LEAF_CERTIFICATE); const [originalTbs] = children(LEAF_CERTIFICATE, whole!.content, whole!.end); const fields = children(LEAF_CERTIFICATE, originalTbs!.content, originalTbs!.end).map(s => LEAF_CERTIFICATE.subarray(s.start, s.end)); const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve:'prime256v1' }); const spki = publicKey.export({ type:'spki', format:'der' }); const name = tlv(0x30, tlv(0x31, tlv(0x30, oid('2.5.4.3'), tlv(0x0c, Buffer.from('REA Outer Metadata'))))); for (const [label, content] of [['control', 'ordinary extension text'], ['embedded-pem', '\n' + pem(ROOT_CERTIFICATE) + '\n']]) { const extension = tlv(0x30, oid('1.3.6.1.4.1.55555.1'), tlv(0x04, tlv(0x0c, Buffer.from(content)))); const tbs = tlv(0x30, fields[0]!, tlv(0x02, Uint8Array.of(0x04,0xd2)), fields[2]!, name, fields[4]!, name, spki, tlv(0xa3, tlv(0x30, extension))); const cert = tlv(0x30, tbs, fields[2]!, tlv(0x03, Uint8Array.of(0), sign('sha256', tbs, privateKey))); const outer = new X509Certificate(pem(cert)); const actual = certificateFacts(cert); console.log(JSON.stringify({ label, selfSignatureValid:outer.verify(publicKey), referenceRawMatchesInput:Buffer.from(outer.raw).equals(cert), expected:{ subject:outer.subject, issuer:outer.issuer, serial:outer.serialNumber }, actual:{ subject:actual.subject, issuer:actual.issuer, serial:actual.serial_number }, fingerprintStillMatches:actual.sha256_fingerprint === outer.fingerprint256.replaceAll(':','').toLowerCase() })); } ``` The probe ran once per control/affected input before this draft. The separate retained provider probe wraps the same certificate construction in v1 and v2 signing records; provider observations above come from that probe and its stored-artifact recheck, not from the reduced reader call alone. ### Expected behavior Subject, issuer, serial and fingerprint should all describe the exact carried DER certificate. Extension text must remain extension data. The [REA certificate contract](https://github.com/morluto/rea/blob/e2efd10b70a2b5f15a7ac1b5a457a9a1dbcab4d4/src/domain/apkSignature/apkSignatureReport.ts#L15) ties readable X.509 facts to the embedded DER; [RFC 5280 section 4.2](https://www.rfc-editor.org/rfc/rfc5280#section-4.2) defines extension values and permits unknown noncritical extensions. Independent checks read the expected outer identity with `openssl x509 -inform DER`. The certificate's self-signature verifies, and the explicit-PEM wrapper used as the reduced probe's reference returns raw bytes identical to the input DER. ### Impact and scope A bounded set of signer certificates receives misleading display metadata while its fingerprint remains correct. Both tested signing schemes expose the mismatch; certificate clustering by subject/issuer/serial can therefore disagree with fingerprint identity. No real APK corpus was measured. v3, rotation and source-stamp callers share this reader by source inspection, but were not exercised. Explicit DER parsing recovers the correct identity from the retained certificate. A complete user-facing APK certificate extraction workaround was not evaluated. ### REA version 6.7.0, source `e2efd10b70a2b5f15a7ac1b5a457a9a1dbcab4d4`. ### Runtime and platform Node.js v24.22.1, Linux 7.2.8-9-omarchy x86_64. Independent reference: OpenSSL 3.6.5. No Android SDK, device or app execution. ### Target and artifact context Benign synthetic EC P-256 certificates, with an ordinary private noncritical extension, placed in local v1/v2 APK-shaped ZIP fixtures. v2 signed-data signatures and content digests match; detached v1 CMS signatures verify. The ZIP's DEX is a placeholder: Android installability and apksigner verification were not tested. ### Interface Other Production provider API and its shared certificate reader. CLI/MCP transports were not exercised. ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence Reduced reader output, with matching fingerprint and valid self-signature in both cases: ```text control: expected/actual subject = CN=REA Outer Metadata expected/actual issuer = CN=REA Outer Metadata expected/actual serial = 04D2 embedded-pem: expected subject = CN=REA Outer Metadata actual subject = CN=REA Test Root\nO=REA expected issuer = CN=REA Outer Metadata actual issuer = CN=REA Test Root\nO=REA expected serial = 04D2 actual serial = 01 ``` The stored provider fixture reports outer fingerprint `e9a2b1a8f22a7ed5ad22f9eb1e7d7f4c88cfbe389a16ed08cbd43fc1cf677a6c`; the unrelated embedded certificate's fingerprint is `b8470a4468a232423983c0b4b0414c43af60ae66ea167fdd05a06a3511ebb04d`. Fresh key generation changes the outer certificate fingerprint. - Evidence provenance: observed production-provider and shared-reader results, with independent certificate/signature checks. - Local verification: reproduced on pinned Node; OpenSSL explicit DER reads the outer subject, issuer and serial, and verifies the stored CMS signatures. - Reproduction completeness: complete reduced reader reproduction; full v1/v2 provider fixtures and outputs retained. No CLI/MCP or Android-installation claim. - [Shared reader](https://github.com/morluto/rea/blob/e2efd10b70a2b5f15a7ac1b5a457a9a1dbcab4d4/src/apkSignature/apkCertificateFacts.ts#L27). - [v1 propagation](https://github.com/morluto/rea/blob/e2efd10b70a2b5f15a7ac1b5a457a9a1dbcab4d4/src/apkSignature/apkV1Signers.ts#L180). - [Block-scheme projection](https://github.com/morluto/rea/blob/e2efd10b70a2b5f15a7ac1b5a457a9a1dbcab4d4/src/apkSignature/ApkSignatureProvider.ts#L224). ### Typed outcome or recovery expected Return facts for the carried DER certificate together with its fingerprint. If those exact bytes cannot be parsed, preserve unknown facts instead of borrowing a nested certificate's metadata. ### Analysis or suspected cause Source-derived cause supported by the probe: `certificateFacts` passes DER bytes to Node's auto-detecting `X509Certificate` constructor and separately hashes the original DER. Node [tries PEM before DER](https://github.com/nodejs/node/blob/v24.22.1/deps/ncrypto/ncrypto.cc#L1616-L1629), so PEM inside the extension supplies the parsed facts while the fingerprint still identifies the outer certificate. ### Additional context Open/closed searches covered `"apk" "signature"`, `"signer"`, `"certificate" "APK"`, `"certificate" "PEM"` and `"APK" "embedded"`. No matching report found. Related but distinct: #6439 concerns issuer DirectoryString matching; #6440 concerns empty-v2 fallback; closed #6603 concerns constructed BER signature values. No known-good REA version was established. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: Production v1/v2 provider results deterministically attribute subject, issuer and serial to a different certificate for these valid DER inputs; the correct raw fingerprint survives and no general inspection failure occurs. - Workaround status: unverified - Workaround basis: Explicit DER parsing recovered the expected metadata from the stored certificate, but a complete practical APK extraction workflow for affected users was not tested.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#4167
No description provided.