DEV Community

Silkline
Silkline

Posted on AI-assisted

What's inside a .pkpass file, and how to render one in the browser

A .pkpass file is an Apple Wallet pass: a boarding pass, a concert ticket, a gym card. On an iPhone it opens straight into Wallet. On Windows or Android it is an attachment nothing wants to open.

I recently built a small viewer that opens these files in the browser without uploading them. Most of the work was understanding the format, so here is what's inside a pass and how each part can be read.

It's a ZIP file

Rename a .pkpass to .zip and you can open it. Inside you'll find:

  • pass.json: the content of the pass, including the barcode data
  • images such as icon.png and logo.png, often with @2x and @3x versions
  • optional en.lproj/, de.lproj/ folders with a pass.strings file each
  • manifest.json and signature

The file comes from a stranger, so unzipping is the first place to be careful. I use fflate, check the ZIP magic bytes, and count uncompressed sizes in the filter callback, which runs before an entry is inflated:

export function readZip(bytes: Uint8Array, budget = new UnpackBudget()): Unzipped {
  if (bytes.byteLength > MAX_INPUT_BYTES) throw new PkpassError('too-large');
  if (!hasZipSignature(bytes)) throw new PkpassError('not-zip');
  return unzipSync(bytes, {
    filter: (entry) => {
      if (isSkippedEntry(entry.name)) return false; // directories, __MACOSX/, .DS_Store
      if (entry.originalSize > MAX_ENTRY_BYTES) throw new PkpassError('unpacked-too-large');
      budget.spend(entry.originalSize);
      return true;
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

One detail that's easy to miss: fflate returns files as a plain object keyed by entry name. An entry named __proto__ would collide with the prototype, so those names are skipped and every lookup uses Object.hasOwn.

pass.json: style and fields

A pass has one style, and the style is a top-level key, not a type field:

export const PASS_STYLES = ['boardingPass', 'eventTicket', 'coupon', 'storeCard', 'generic'] as const;

function detectStyle(json: JsonObject): PassStyle {
  const style = PASS_STYLES.find((name) => isObject(json[name]));
  if (!style) throw new PkpassError('not-a-pass');
  return style;
}
Enter fullscreen mode Exit fullscreen mode

Inside it are five field lists: headerFields, primaryFields, secondaryFields, auxiliaryFields and backFields. Values need formatting. A date with dateStyle: "PKDateStyleShort" maps onto Intl.DateTimeFormat's dateStyle. ignoresTimeZone: true means "show the wall-clock time as written", so I strip the offset and format in UTC. Numbers with a currencyCode go to Intl.NumberFormat. attributedValue can hold HTML links; I reduce it to plain text, and every string from the pass reaches the page through textContent.

Localization: .lproj and .strings

Translations live in <language>.lproj/pass.strings, an old Apple format of "key" = "value"; pairs. Apple's docs say non-ASCII .strings files use UTF-16, so check for a byte-order mark before decoding:

export function decodeStringsFile(bytes: Uint8Array): string {
  if (startsWith(bytes, UTF16_LE_BOM)) return new TextDecoder('utf-16le').decode(bytes.subarray(2));
  if (startsWith(bytes, UTF16_BE_BOM)) return new TextDecoder('utf-16be').decode(bytes.subarray(2));
  return decodeUtf8(bytes);
}
Enter fullscreen mode Exit fullscreen mode

You also need a rule for picking one language. Mine is en, then any en-*, then the first folder alphabetically. A missing key falls back to the untranslated text, and the parser keeps every pair it read before a syntax error.

Images and @2x / @3x

There are six image roles: logo, icon, strip, thumbnail, background and footer, each at up to three scales. Apple says an image should live either at the top level or inside a .lproj folder, never both. Some passes break that rule anyway, so the viewer picks one predictable order and checks the top level first:

const IMAGE_SCALES = ['@2x', '@3x', ''];

function findImage(files: Unzipped, name: PassImageName, language?: string): Uint8Array | undefined {
  const folders = language ? ['', `${language}.lproj/`] : [''];
  for (const folder of folders) {
    for (const scale of IMAGE_SCALES) {
      const bytes = getFile(files, `${folder}${name}${scale}.png`);
      if (bytes) return bytes;
    }
  }
  return undefined;
}
Enter fullscreen mode Exit fullscreen mode

Before an image reaches the browser, I read its width and height from the PNG's IHDR chunk without decoding it. Non-PNGs, files over 5 MB and anything over 2000×2000 pixels are skipped, so a crafted image can't make the tab allocate a huge bitmap.

manifest.json and signature

manifest.json maps every file in the pass to its SHA-1 hash, and signature is a PKCS #7 detached signature of the manifest, made with the issuer's certificate. That's why you can't edit a pass: change one byte and the hashes no longer match what was signed.

My viewer doesn't read either file. It draws what pass.json says. That keeps it simple, but any tool like this should say the consequence plainly: a pass that renders fine is not proof that it's genuine or that Wallet will accept it.

Barcodes

The barcode lives in barcodes, an array of { format, message, messageEncoding, altText }. Wallet uses the first one it can display; older passes use a single barcode object. So I take the first supported entry of barcodes, then fall back to barcode. All eight formats map onto bwip-js:

export const BARCODE_SYMBOLOGIES = {
  PKBarcodeFormatQR: 'qrcode',
  PKBarcodeFormatPDF417: 'pdf417',
  PKBarcodeFormatAztec: 'azteccode',
  PKBarcodeFormatCode128: 'code128',
  PKBarcodeFormatCode39: 'code39',
  PKBarcodeFormatCodabar: 'rationalizedCodabar',
  PKBarcodeFormatEAN13: 'ean13',
  PKBarcodeFormatI2of5: 'interleaved2of5',
} as const;
Enter fullscreen mode Exit fullscreen mode

The eight encoders are imported by name from bwip-js/browser, so the bundler can drop the rest, and loaded with import() on first use.

messageEncoding matters for non-ASCII text. bwip-js turns text into UTF-8 unless binarytext: true is set, in which case each character is one byte, which is ISO-8859-1. So: ISO-8859-1 messages that fit in Latin-1 go in as single bytes, everything else as plain UTF-8 with no ECI header, since older gate scanners may not understand one.

For the gate there's a full-screen view. It re-encodes the barcode at the largest whole number of device pixels per module that fits, so module edges stay sharp, and asks for a screen wake lock so the phone doesn't dim in the queue.

.pkpasses bundles

A .pkpasses file is a ZIP of .pkpass files, so opening one is the same function with one more branch:

export function openPassFile(bytes: Uint8Array): OpenResult {
  const budget = new UnpackBudget();
  const files = readZip(bytes, budget);
  if (getFile(files, PASS_JSON)) return { kind: 'pass', pass: parsePassArchive(files) };
  const items = readBundle(files, budget);
  if (items.length > 0) return { kind: 'bundle', items };
  throw new PkpassError('missing-pass-json');
}
Enter fullscreen mode Exit fullscreen mode

The same budget covers the outer ZIP and every pass inside, so nesting can't get around the size limit. A broken pass becomes an error entry instead of failing the bundle.

Summary

  • A .pkpass is a ZIP. Check sizes before inflating and look files up with Object.hasOwn.
  • The style is whichever of five top-level keys exists. Field values need Intl formatting.
  • .strings files may be UTF-16 with a BOM. Fall back to the untranslated text.
  • An image belongs at the top level or in a .lproj folder, not both; pick a fixed lookup order for passes that break the rule. Read PNG dimensions before decoding.
  • manifest.json and signature protect the pass from edits. If you don't verify them, say so.
  • bwip-js covers all eight PassKit formats, and messageEncoding maps onto binarytext.

If you need to look at a pass on a computer or an Android phone, the viewer is free at pkpassviewer.com, and the file never leaves your browser.

Top comments (0)