PassMaker is an iOS app for creating Apple Wallet passes from text prompts and images. For the past year, its "Add to Wallet" flow depended on Firebase Cloud Functions solely to run one shell command:

openssl smime -sign -binary \
  -in manifest.json \
  -signer signerCert.pem \
  -certfile wwdr.pem \
  -inkey signerKey.pem \
  -outform DER -out signature -passin stdin

Everything else in PassMaker's pass generation pipeline had been migrated to Cloudflare Workers. Signing remained on Firebase because Workers are V8 isolates: no filesystem, no child processes, and no OpenSSL binary.

The goal was straightforward: let PassMaker produce the same detached PKCS#7 signature that openssl smime -sign produces, but using TypeScript and Web Crypto inside a Worker.

The Problem

PKCS#7 is an old, complex ASN.1-based standard. openssl smime -sign produces a detached SignedData envelope containing:

  1. The signer certificate and the Apple WWDR intermediate certificate as raw DER
  2. Four authenticated attributes (contentType, signingTime, messageDigest, SMIMECapabilities)
  3. An RSA-SHA256 signature over the DER-encoded authenticated attributes (not the content)
  4. A specific issuer-and-serial-number structure extracted from the signer certificate's TBS certificate section

Every byte matters. A single incorrect length field cascades through the entire structure. Apple's PassKit validator on iOS is unforgiving: the pass either opens or it does not.

What Didn't Work

Three approaches were useful dead ends before the final implementation.

Approach 1: pkijs library

The pkijs npm package can build PKCS#7. It produced signatures that verified with OpenSSL's smime -verify, but Apple Wallet rejected them. The issue was certificate re-encoding. Once the certificate bytes changed, the structure no longer matched what PassKit expected from the OpenSSL-generated signature.

Approach 2: Manual Uint8Array concatenation with hardcoded DER fragments

The next attempt hand-rolled every byte: contentType, signingTime, messageDigest, SMIMECapabilities, and raw certificate DER bytes from PEM files. This was fragile because DER length encoding changes at boundary values like 128 and 256 bytes.

One version treated a 256-byte RSA signature as if it could be represented with a single length byte:

// Bug: 256 cannot be encoded as 0x81 0x00.
function encodeLength(len: number): Uint8Array {
  if (len < 128) return new Uint8Array([len]);
  if (len <= 256) return new Uint8Array([0x81, len]);

  const bytes = [];
  while (len > 0) {
    bytes.unshift(len & 0xff);
    len >>>= 8;
  }
  return new Uint8Array([0x80 | bytes.length, ...bytes]);
}

For an RSA-2048 signature, the encrypted digest is 256 bytes. DER must encode that length as 0x82 0x01 0x00, not 0x81 0x00. Getting this wrong breaks the OCTET STRING length and every enclosing SEQUENCE length.

Approach 3: Extracting issuer from certificate TBS

Another attempt extracted the issuer Distinguished Name from the signer certificate's TBS certificate section and re-encoded it with toBER(). That still changed bytes because string types and RDN encoding choices must match the original certificate exactly. Hardcoding issuer bytes from a known-good signature worked, but certificate renewal would have broken it.

The Solution

The final implementation uses a small compositional DER builder instead of hardcoded hex fragments or library re-encoding:

function tlv(tag: number, content: Uint8Array): Uint8Array {
  return concatBytes(new Uint8Array([tag]), encodeLength(content.length), content);
}

function sequence(...parts: Uint8Array[]): Uint8Array {
  return tlv(0x30, concatBytes(...parts));
}

function algorithmIdentifier(algorithmOid: string): Uint8Array {
  return sequence(oid(algorithmOid), nullValue());
}

From these primitives, the PKCS#7 structure is composed directly:

const signedData = sequence(
  integerFromNumber(1), // version
  set(digestAlgorithm), // digestAlgorithms
  sequence(oid(DATA_OID)), // encapContentInfo (detached)
  explicitContext0(concatBytes(wwdrDer, signerCertDer)), // certificates
  set(signerInfo) // signerInfos
);

The issuer and serial number are copied directly from the signer certificate's DER bytes. No hardcoded issuer data, no library re-encoding:

function signerIssuerAndSerialNumber(signerCertDer: Uint8Array): Uint8Array {
  const certificate = readElement(signerCertDer);
  const tbsCertificate = readChildren(certificate)[0];
  const tbsChildren = readChildren(tbsCertificate);
  const hasExplicitVersion = tbsChildren[0]?.tag === 0xa0;
  const serialIndex = hasExplicitVersion ? 1 : 0;
  const serialNumber = tbsChildren[serialIndex];
  const issuer = tbsChildren[serialIndex + 2];

  return sequence(issuer.full, serialNumber.full);
}

This preserves the exact issuer and serial DER bytes from the certificate.

The Result

With the same signingTime, PassMaker's Worker now produces byte-identical PKCS#7 signatures to openssl smime -sign. The comparison test fixes signingTime because RSA PKCS#1 v1.5 signing is deterministic, but the authenticated attributes include the current time.

# Generate OpenSSL's signature
printf "%s\n" "$KEY_PASSWORD" | openssl smime -sign -binary \
  -in manifest.json \
  -signer signerCert.pem \
  -certfile wwdr.pem \
  -inkey signerKey.pem \
  -outform DER \
  -passin stdin \
  -out openssl.sig

# Extract signingTime for byte-identical comparison
TIME=$(openssl asn1parse -inform DER -in openssl.sig | \
  grep UTCTIME | tail -1 | awk '{print $NF}')

# Get Workers sig with matching time
curl -s "$WORKERS_URL?signingTime=$TIME" -d "$MANIFEST" \
  -o workers.sig

# Compare
cmp openssl.sig workers.sig && echo "IDENTICAL"
# Prints: IDENTICAL

The DER and signing logic now live in a published package:

npm install @swapnanildhol/passkit-pkcs7-signature

The Worker handler stays small because it only needs to pass the manifest, signer certificate, PKCS#8 private key, and WWDR certificate into the package:

import { createOpenSslCompatiblePkcs7Signature } from '@swapnanildhol/passkit-pkcs7-signature';

export async function createSignature(c: Context) {
  const body = await c.req.text();
  const env = c.env as SigningEnv;
  const sig = await createOpenSslCompatiblePkcs7Signature({
    manifest: body,
    signerCertPem: env.SIGNER_CERT_PEM,
    privateKeyPkcs8Pem: env.SIGNER_KEY_PKCS8_PEM,
    wwdrPem: env.WWDR_PEM,
  });
  c.header('Content-Type', 'application/octet-stream');
  c.header('Content-Disposition', 'attachment; filename=signature');
  const responseBody = new Uint8Array(sig.length);
  responseBody.set(sig);
  return c.body(responseBody.buffer);
}

For PassMaker, this removes the last Firebase dependency from pass signing. No OpenSSL, no native binaries, and no hardcoded certificate bytes.

What We Learned

  1. Byte-level ASN.1 encoding is unforgiving. A one-byte length field error cascades. Build proper DER primitives instead of hand-rolling hex arrays.

  2. Certificate re-encoding changes bytes. Libraries that parse and re-encode certificates produce different DER. Copy raw certificate bytes whenever possible.

  3. PKCS#7 signs the authenticated attributes, not the content. This is the most common mistake. The messageDigest attribute contains the content hash. The signature covers the DER-encoded signedAttrs SET, not the raw content.

  4. Web Crypto works for real signing. crypto.subtle.sign('RSASSA-PKCS1-v1_5') produces the same RSA signature as OpenSSL. The challenge is the PKCS#7 envelope, not the crypto.

Use It

This implementation is packaged as @swapnanildhol/passkit-pkcs7-signature, a zero-dependency TypeScript package for runtimes with the Web Crypto API: Cloudflare Workers, browsers, Deno, Bun, and Node.js 19+.

To use it in a Worker, deploy with three wrangler secrets: SIGNER_CERT_PEM, SIGNER_KEY_PKCS8_PEM, and WWDR_PEM.

PassMaker lets you create Apple Wallet passes from text prompts and images. Try it on the App Store.