DEV Community

Silkline
Silkline

Posted on Edited on AI-assisted

Checking a .pkpass in the browser: manifest hashes, the PKCS #7 signature, and what a green tick can't tell you

In an earlier post I went through what's inside an Apple Wallet .pkpass file and how my browser viewer draws one. The section on manifest.json and signature ended with an admission: the viewer doesn't read either file.

This post is the follow-up. What does checking those two files take in a browser, what does a passing result actually prove, and why does the viewer still not do it?

All the code below is TypeScript that compiles under strict and that I ran against seven test files: passes I signed with a throwaway certificate authority using openssl smime, tampered copies of them, the unsigned sample pass from my own site, and one real pass from 2014, signed by Apple's chain, that sits in an open-source Android wallet's test files.

What the two files promise

manifest.json maps each file in the pass to the SHA-1 hash of its bytes, as lowercase hex. The 2014 pass, for example (hashes replaced):

{"icon.png":"<40 hex characters>","pass.json":"<40 hex characters>", ...}
Enter fullscreen mode Exit fullscreen mode

signature is a DER-encoded PKCS #7 (CMS) SignedData structure. It is detached: the manifest's bytes are not inside it, you supply them. Together they say: these exact files, signed by the holder of this certificate.

So there are three checks, from cheap to expensive: do the files match the manifest, does the signature match the manifest, and who does the certificate belong to.

Step 1: unzip without skipping anything

My viewer unzips with fflate and quietly skips some entries, which is right for drawing a pass:

function isSkippedEntry(name: string): boolean {
  return (
    name.endsWith('/') ||
    name.startsWith('__MACOSX/') ||
    name.split('/').pop() === '.DS_Store' ||
    PROTOTYPE_KEYS.has(name)
  );
}
Enter fullscreen mode Exit fullscreen mode

For checking, it's wrong. A .DS_Store or a stray file that isn't in the manifest is exactly what you want to report. But one rule has to stay: fflate returns the files as a plain object keyed by entry name, so an entry called __proto__ would land on the prototype. The way out is to record every name separately and only keep the dangerous ones out of the object:

export function readForCheck(bytes: Uint8Array): { files: Unzipped; names: string[] } {
  const names: string[] = [];
  const budget = new UnpackBudget();
  const files = unzipSync(bytes, {
    filter: (entry) => {
      if (entry.name.endsWith('/')) return false;
      names.push(entry.name);
      budget.spend(entry.originalSize);
      return !PROTOTYPE_KEYS.has(entry.name);
    },
  });
  return { files, names };
}
Enter fullscreen mode Exit fullscreen mode

UnpackBudget is the viewer's existing size limit (60 MB uncompressed for one opened file), counted before each entry is inflated. A test pass with a __proto__ entry comes out as not in manifest: __proto__, and the object's prototype is untouched.

Step 2: the manifest, in both directions

crypto.subtle.digest supports SHA-1, so this needs no library. Note that crypto.subtle only exists on HTTPS pages (and localhost).

/** A copy as a plain ArrayBuffer, which is what WebCrypto's and PKI.js's types ask for. */
const ownBuffer = (bytes: Uint8Array): ArrayBuffer => new Uint8Array(bytes).buffer;

const toHex = (buffer: ArrayBuffer): string =>
  Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, '0')).join('');

export async function checkManifest(files: Unzipped, names: string[]): Promise<string[]> {
  const manifestBytes = getFile(files, 'manifest.json');
  if (!manifestBytes) return ['no manifest.json'];
  const manifest: Record<string, string> = JSON.parse(new TextDecoder().decode(manifestBytes));
  const problems: string[] = [];
  for (const [path, expected] of Object.entries(manifest)) {
    const bytes = getFile(files, path);
    if (!bytes) {
      problems.push(`listed but missing: ${path}`);
      continue;
    }
    const actual = toHex(await crypto.subtle.digest('SHA-1', ownBuffer(bytes)));
    if (actual !== String(expected).toLowerCase()) problems.push(`hash mismatch: ${path}`);
  }
  for (const name of names) {
    if (name === 'manifest.json' || name === 'signature') continue;
    if (!Object.hasOwn(manifest, name)) problems.push(`not in manifest: ${name}`);
  }
  return problems;
}
Enter fullscreen mode Exit fullscreen mode

getFile is the viewer's own-property lookup (Object.hasOwn), so a manifest that lists constructor can't resolve to something on Object.prototype.

Results on the test files: a pass where I changed one value in pass.json gives hash mismatch: pass.json; one with an extra file gives not in manifest: extra.txt. The real 2014 pass gives four listed but missing lines: the test copy kept only pass.json, but the manifest still lists the icon and logo images.

This step alone proves little. Anyone who edits pass.json can recompute its hash and write a new manifest. That's what the signature is for.

Step 3: the signature

Browsers have no CMS parser, so this uses PKI.js and its ASN.1 library, which do the crypto through WebCrypto:

export async function checkSignature(files: Unzipped) {
  const der = getFile(files, 'signature');
  const manifest = getFile(files, 'manifest.json');
  if (!der || !manifest) return { verified: false, reason: 'no signature or manifest' };
  try {
    const signedData = new pkijs.SignedData({ schema: pkijs.ContentInfo.fromBER(ownBuffer(der)).content });
    const result = await signedData.verify({ signer: 0, data: ownBuffer(manifest), extendedMode: true });
    const cert = result.signerCertificate!;
    const attributes = signedData.signerInfos[0].signedAttrs?.attributes ?? [];
    return {
      verified: result.signatureVerified === true,
      passTypeId: nameValue(cert.subject, OID.userId),
      teamId: nameValue(cert.subject, OID.orgUnit),
      signer: cert,
      validFrom: cert.notBefore.value,
      validTo: cert.notAfter.value,
      signingTime: attributes.find((a) => a.type === OID.signingTime)?.values[0].toDate() as Date | undefined,
      certificates: (signedData.certificates ?? []) as pkijs.Certificate[],
    };
  } catch (error) {
    return { verified: false, reason: String(error) };
  }
}
Enter fullscreen mode Exit fullscreen mode

Because the signature is detached, the manifest goes in as data. verify hashes it with the signer's digest algorithm, compares that with the messageDigest attribute inside the signature, then checks the RSA signature over the signed attributes with the certificate's public key. The 2014 pass uses SHA-1 throughout; OpenSSL 3 signed my test passes with SHA-256. Both verify.

The try is not optional. When I changed the manifest without re-signing, PKI.js 3.4 didn't return false; it threw Message digest doesn't match.

Step 4: whose certificate is it?

A valid signature only means "someone with this certificate signed this manifest". The certificate's subject says who. On the 2014 pass it looks like this (names replaced):

UID=pass.<reverse domain>.ticket, CN=Pass Type ID: pass.<reverse domain>.ticket, OU=<10-character team ID>, O=<company name>, C=<country>
Enter fullscreen mode Exit fullscreen mode

UID is the pass type identifier and OU is the Apple team identifier. Apple's documentation says passTypeIdentifier and teamIdentifier in pass.json must match the signing certificate, so compare them:

const OID = {
  userId: '0.9.2342.19200300.100.1.1', // UID: the pass type identifier
  orgUnit: '2.5.4.11', // OU: the team identifier
  signingTime: '1.2.840.113549.1.9.5',
};

const nameValue = (name: pkijs.RelativeDistinguishedNames, oid: string): string | undefined =>
  name.typesAndValues.find((item) => item.type === oid)?.value.valueBlock.value;
Enter fullscreen mode Exit fullscreen mode

But nothing so far ties the certificate to Apple. I can make a certificate with any UID I like, and my throwaway test CA did exactly that.

Step 5: the chain to Apple

The signature in the 2014 pass carries two certificates: the pass certificate and the Apple Worldwide Developer Relations intermediate that issued it. The root is not included; you bring it. Apple publishes its root and intermediate certificates at apple.com/certificateauthority. That page lists several WWDR intermediates today (G2 to G6, with different expiry dates), so build the chain from the certificates in the signature up to a root you trust, rather than pinning one intermediate.

export async function checkChain(certificates: pkijs.Certificate[], appleRoot: pkijs.Certificate, at: Date) {
  const engine = new pkijs.CertificateChainValidationEngine({
    trustedCerts: [appleRoot],
    certs: certificates,
    checkDate: at,
  });
  const { result, resultMessage } = await engine.verify();
  return { ok: result, message: resultMessage };
}
Enter fullscreen mode Exit fullscreen mode

Results:

Test Result
2014 pass, Apple Root CA, at its signing time (2014-06-24) valid
2014 pass, Apple Root CA, today The certificate is either not yet valid or expired
2014 pass, my test root instead No valid certificate paths found
My test passes, Apple Root CA No valid certificate paths found

Two details matter here. The pass certificate expired on 2014-09-16, so "valid today" fails for any pass whose certificate has expired, which is normal for a ticket you kept. And the signing time is a signed attribute that the signer's machine wrote: it tells you when the signer says they signed, not a trusted timestamp.

The certificates also list CRL and OCSP addresses for revocation. Checking those is a network request to Apple, and this code doesn't make one.

Step 6: is it a pass certificate?

A path to Apple's root only says the Worldwide Developer Relations CA issued the certificate. That CA issues many kinds: app signing, push notifications, Apple Pay and more. The chain check doesn't care which kind it is.

Apple's WWDR Certification Practice Statement (version 1.29, section 4.11.11, "Pass Certificates") lists the extensions in a pass certificate, including an Apple custom extension with OID 1.2.840.113635.100.6.1.16. The statement doesn't say what the extension holds. In the 2014 certificate it is a UTF8String containing the pass type identifier, the same value as the UID. So check that it's there and matches passTypeIdentifier:

/** Apple custom extension listed in every pass certificate profile of Apple's WWDR CPS (4.11.11). */
const PASS_CERT_EXTENSION = '1.2.840.113635.100.6.1.16';

/** The pass type ID stored in that extension, or undefined if the certificate doesn't have it. */
export function passCertificateId(cert: pkijs.Certificate): string | undefined {
  const extension = cert.extensions?.find((item) => item.extnID === PASS_CERT_EXTENSION);
  if (!extension) return undefined;
  const inner = asn1js.fromBER(extension.extnValue.getValue());
  return inner.result instanceof asn1js.Utf8String ? inner.result.getValue() : undefined;
}
Enter fullscreen mode Exit fullscreen mode

cert here is the signer that checkSignature returns in step 3. On the 2014 pass this returns the pass type identifier from pass.json. On my test passes it returns undefined, because my test CA never added the extension.

Two limits. I've seen the extension's contents in only one real pass certificate, so if a checker finds the OID but a value it can't read, report that instead of failing the pass outright. And the extension is only meaningful together with step 5: anyone can put any extension in a certificate their own CA signs.

What a green result proves, and what it doesn't

If all six steps pass, you know the files haven't changed since they were signed by someone holding a certificate that chains to Apple's root, carries Apple's pass certificate extension, and names that pass type and team.

You don't know:

  • That it's from who you think. Any Apple developer can get a pass certificate for their own team. Copy an airline's pass.json, change the name, set passTypeIdentifier and teamIdentifier to your own, sign it, and every check above passes. Only comparing the team ID with the airline's real one catches that, and a generic viewer doesn't know which team ID to expect.
  • That it's still valid. A pass can be refunded or voided after it was issued. The voided key is only what this copy says; the current state lives with the issuer.
  • That it will scan at the gate, or that Wallet will add it. The scanner checks the barcode against the issuer's records, and Wallet has its own checks.

Why the viewer still doesn't do it

Size is one reason. Bundled with esbuild and minified, the code above comes to about 410 KB, or 89 KB gzipped, almost all of it PKI.js and asn1js. That's a lot for a page whose job is to show a boarding pass.

The bigger reason is what a check would look like on screen. A green "signature valid" reads as "this ticket is genuine", and the list above shows it means much less. So the viewer opens a pass, draws it, and says on its pages that it doesn't check the signature. It opens the unsigned sample pass on my own site, which has a manifest.json but no signature, and it opened my tampered test pass without comment. If you build a checker, show what each step found, not one verdict.

Summary

  • Record every ZIP entry name for checking, but keep __proto__ and friends out of the object fflate returns.
  • Check the manifest both ways: every listed file matches, every file is listed.
  • The signature is detached CMS: pass the manifest bytes as data, and catch errors, since a digest mismatch can throw.
  • Compare the certificate's UID and OU with passTypeIdentifier and teamIdentifier.
  • Chain to Apple's root yourself, and decide which date you check against. The signing time is the signer's claim.
  • Then check the signer certificate carries Apple's pass certificate extension (1.2.840.113635.100.6.1.16) for the same pass type ID.
  • A passing result still doesn't say who the issuer should be, whether the pass was voided, or whether it will scan.

For what each file in a pass does, without the code, there's a plain-English guide to the .pkpass format on the viewer's site.

Top comments (0)