@erc8426/apple
v0.4.0
Published
Apple Wallet delivery for ERC-8426: pkpass building, the PassKit web service, APNs push
Maintainers
Readme
@erc8426/apple
Apple Wallet delivery for ERC-8426 wallet passes: signed .pkpass building, the PassKit web service that keeps installed passes current, APNs pushes, and a format provider that serves the apple entry of a pass manifest.
The standard leaves generation, signing and push out of scope. This package fills that gap with the patterns of two production deployments, so an issuer describes a pass once as a PassContent (from @erc8426/core) and gets a working Apple pass.
What you need from Apple
An Apple Developer Program membership and your Team ID (Membership details page).
A Pass Type ID, for example
pass.com.example.collection, under Certificates, Identifiers and Profiles.A Pass Type ID certificate. Create it for that identifier, download the
.cer, open it in Keychain Access and export certificate plus private key as a.p12. Convert it to PEM:# OpenSSL 3 needs -legacy for most Keychain exports. openssl pkcs12 -legacy -in pass.p12 -clcerts -nokeys -out signerCert.pem openssl pkcs12 -legacy -in pass.p12 -nocerts -out signerKey.pemThe second command asks for a PEM pass phrase; pass the same value as
signerKeyPassphrase. Add-nodesinstead to write an unencrypted key and keep it in a secret manager.The Apple WWDR intermediate, G4. Pass Type ID certificates issued today chain to G4, and a bundle signed against the wrong intermediate installs nowhere and reports nothing.
curl -O https://www.apple.com/certificateauthority/AppleWWDRCAG4.cer openssl x509 -inform der -in AppleWWDRCAG4.cer -out wwdr.pemPush credentials (optional, for live updates). The Pass Type ID certificate itself is the APNs client credential for pass updates, and certificate auth is the path both production deployments run. Token auth with an APNs
.p8key (Keys page, "Apple Push Notifications service") is also supported; confirm it against your pass type topic before relying on it, because Apple has historically accepted only certificates for Wallet topics.
Never commit any of these. Load them from a secret store at startup.
The web service URL
A pass updates only if it carries a webServiceURL, and Apple only calls one that is https on a public host with a certificate that chains to a public root. On an http origin (local development) the provider omits the web service, so passes still install but never update. Pass pushes always travel through the production APNs gateway, development passes included.
Two modes
With @erc8426/issuer (recommended). The provider implements core's PassFileProvider. The issuer calls apple.passFile(ctx), receives the signed .pkpass (contentType: application/vnd.apple.pkpass, filename: pass.pkpass), and serves it at its own rotating 256-bit capability URL, so download hosting and rotation live in one place. Omit linkSecret, and mount only apple.webService at basePath so devices can register and fetch updates. notifyUpdate pushes through APNs as usual. A new owner seen by passFile still rotates the pass's device token and pushes the previous holder their superseded pass.
Standalone. Without the issuer, pass a linkSecret and mount apple.handle, which serves both the web service and the download route. apple.acquisitionUrl(ctx) then returns a capability URL on your origin.
Wiring (standalone)
The handlers are Fetch API functions, (Request) => Promise<Response>, so they mount in Next.js route handlers, Hono, Bun, or Node's http behind a small adapter.
import { appleFormatProvider, createApnsClient, MemoryApplePassStore } from "@erc8426/apple";
const store = new MemoryApplePassStore(); // replace with a persistent ApplePassStore
const certificates = {
signerCert: process.env.PASS_SIGNER_CERT_PEM!,
signerKey: process.env.PASS_SIGNER_KEY_PEM!,
signerKeyPassphrase: process.env.PASS_SIGNER_KEY_PASSPHRASE,
wwdr: process.env.APPLE_WWDR_G4_PEM!,
};
const apns = createApnsClient({
certificate: { cert: certificates.signerCert, key: certificates.signerKey, passphrase: certificates.signerKeyPassphrase },
passTypeIdentifier: "pass.com.example.collection",
store,
});
export const apple = appleFormatProvider({
passTypeIdentifier: "pass.com.example.collection",
teamIdentifier: "ABCDE12345",
certificates,
origin: "https://passes.example.com",
basePath: "/apple",
linkSecret: process.env.PASS_LINK_SECRET!, // 32+ random bytes, never stored with the passes
store,
apns,
images: { icon: { url: "https://passes.example.com/icon.png" }, logo: { url: "https://passes.example.com/logo.png" } },
});
// Next.js: app/apple/[...path]/route.ts
export const GET = (req: Request) => apple.handle(req);
export const POST = GET;
export const DELETE = GET;When the issuer resolves a manifest it calls apple.acquisitionUrl({ token, owner, content }) and puts the result under formats.apple. When content changes (a PassUpdate event, a balance move) it calls apple.notifyUpdate(...), which stores the new content, bumps Last-Modified, and pushes every registered device. For an owner's request to reset their links without the issuer, call apple.rotate(serial, freshContent): every existing copy, a leaked one included, refreshes into the superseded card and can no longer register devices, and the owner's new download URL carries the fresh content. (With the issuer, rotation mints a new serial and voids the old one; voided content retires the old serial's token the same way.)
Field limits
Apple drops overflow fields silently, so toPassJson refuses them instead, the way it refuses a duplicate key: up to three header fields on every style; on generic and storeCard one primary field and at most four secondary and auxiliary fields combined; on posterGeneric one header field, up to four primary fields and one footer field (WWDC26 session 209 shows a single footer field and says further ones are not displayed). eventTicket and coupon lay their rows out differently, and a posterGeneric pass's legacy dictionary carries the poster's fields as Apple's sample does; neither is capped here.
Featured Actions (iOS 27)
iOS 27 draws up to two featured actions beneath the pass face, as cards with an icon and Apple's own localized call to action for the action type, in the order given. Set featuredActions on the pass content; each entry is an absolute https url, or link: <key> naming one of the content's links, whose capability URL is filled in at render time and so rotates with the pass. Older iOS ignores the key; Google has no equivalent.
render: ({ serial, links }) => ({
...content,
links: [{ key: "feed", label: "Feed", url: links.feed }],
featuredActions: [
{ id: "feed", type: "addToBalance", link: "feed" },
{ id: "offers", type: "membershipBenefits", url: "https://issuer.example/offers" },
],
}),Types: membershipBenefits, viewOffersRewards, place, call, order, shop, addToBalance, bookAppointment, bookCar, bookFlight, bookStay, viewSchedule, watchTrailer, listenToMusic (FeaturedActionType, FEATURED_ACTION_TYPES). The list is closed: the signing library validates against it and an unknown type fails at signing with an opaque "type is missing", so toPassJson refuses it by name first. place is different: it opens Apple Maps at a place and takes placeIdentifier (an Apple Maps place id) instead of a url or link. The call to action is Apple's wording for the type, not yours, so pick the type that names what the holder gets. More than two, a duplicate id, a non-https or scheme-less URL, or a link the content does not carry is refused at render time. A superseded pass drops every featured action that points at a link, and supersededContent carries none.
Security note: a featured action sits on the front of the pass and in every screenshot of it, where a back-field link does not. The spec's on-screen Security Consideration applies to it in full: a capability link shown as a featured action is exactly as forwardable as the pass face, so it should reach only an action with a documented bound, and rotation on the owner's request stays the remedy.
Security properties
- Per-pass authentication tokens. Every serial gets its own random 256-bit token, compared in constant time. A leaked token exposes one pass, not the collection.
- Capability download URLs. The acquisition URL is
<origin><basePath>/passes/<serial>/<capability>.pkpass, where the capability is an HMAC of the pass's current token underlinkSecret. It is not derivable from the serial, token id or any public data, it needs no table of its own, and a read-only dump of the store alone does not yield working URLs. Unknown serials and wrong capabilities both answer 404. - Rotation on transfer. When a different owner acquires the pass, its token rotates: the previous holder's download URL stops resolving, and their installed pass, on its next refresh, receives a superseded rendering (voided, no links, no barcode, no live values) that says why: "Transferred" for a change of hands, "Links reset" with a pointer to the current pass for an owner's reset. Pass
supersede(content, reason)for your own wording. A retired token may refresh and unregister its own device but can never register a new one, and the refresh embeds the retired token, never the new owner's. - Bounded pre-auth input.
/v1/logis unauthenticated by Apple's design: its body is capped at 16 KiB while streaming (413 past it), and entries are capped in count and length and stripped of control characters beforeonLogsees them. Registration bodies are capped at 1 KiB.
A pass is a projection of the token, not the token. Nothing in this package treats holding a pass, or its download URL, as proof of ownership; state-changing actions reached from pass links need the spec's authorization checks, including the fresh entitlement read.
Building blocks
buildPkpass(content, options)signs a bundle;toPassJsonis the pure mapping. Styles map togeneric,eventTicket,storeCard,coupon,posterGeneric. Links become back fields withattributedValueanchors. Field keys must be unique across the whole pass; a duplicate throws, because Apple would drop it silently. Images must be PNG; URL sources are fetched at build time with a timeout and size cap, over https only, to public addresses only (every resolved address and every redirect hop is checked, so a URL from token metadata cannot reach your internal network or cloud metadata).imageFetch.allowPrivateNetworkrelaxes this for local development. An icon is required.posterGeneric(iOS 27 and later). The pass carries two dictionaries:posterGenericwithheader(one field),primary(up to four),footer(one) and the back, and a legacyposterFallbackdictionary (defaultgeneric) with the full field set, so iOS 26 and earlier render the legacy layout. Images:artwork358x448 points (a material strip and the QR cover its bottom edge) andprimaryLogo30 points tall and 30 to 126 wide, plus the fallback style's own slots. Mind the fallback's own layout:generichas no strip, so on iOS 26 and earlier agenericfallback shows athumbnailor no art at all; if the legacy card should keep a banner, pair the poster withstoreCardorcouponand supplyhero(device-checked on iOS 26.6.2: astoreCardfallback renders the strip, agenericfallback without a thumbnail renders bare). More fields than the poster face shows throws, because the device would drop them silently. Needs passkit-generator 3.6 or later (the dependency range already resolves it); 3.5 strips theposterGenerickey at signing without a word. Apple's reference: "Creating a Poster Generic Pass" in the WalletPasses documentation.- Signer certificate check.
buildPkpassrefuses a certificate whose subject UID is not thepassTypeIdentifieror whose OU is not theteamIdentifier(validateSignerCertificate). Apple issues Pass Type ID certificates with exactly those attributes; a mismatched certificate signs happily and the pass installs nowhere. A certificate with no UID (a bare development certificate) is not judged. applePassKitWebService({ store, passTypeIdentifier, buildPass, authenticate?, basePath?, onLog? })implements register, unregister, updated serials, latest pass (Last-ModifiedandETag; 304 on a matchingIf-None-Match, or onIf-Modified-Sincewhen that header is absent;passEtagis the tag) and log.appleFormatProvider({ expireSuperseded: true })gives a superseded pass anexpirationDatein the past as well asvoided. Off by default; the reason to turn it on is an observation on one deployment's phones, described under "A voided pass may stay in the main stack" in docs/platforms/apple.md.createApnsClient(...)keeps one HTTP/2 session open and multiplexes, retires it on error, GOAWAY, a stalled stream or a quiet spell, retries tokens that never got a stream once, and removes tokens APNs answers 410 for. Every push is sent once unless you passretry. Withretry: {}a push that got no answer at all (a failed connect, a stream error or timeout) is sent again after a short delay, three sends in all by default (attempts,baseDelayMs,maxDelayMs, andmaxAddedMs, which bounds how much longer a failingsendcan take: 25 seconds at most with the defaults), and a 403ExpiredProviderTokenis resent once on a new token. A 429 and a 5xx are never resent insidesend: Apple asks for a delay after the first and 15 minutes after the second. EveryPushOutcomecarriesattempts; a failed one carriesretryable(true when a later push can land) and thenretryAfterSeconds, a suggested wait. See "A lost push" in docs/platforms/apple.md.apnsRetryAdvice(outcome)is the classification on its own.MemoryApplePassStoreimplementsApplePassStore(pass records plus device registrations) for tests and demos. A production store needs the same nine methods over a database,supersede()included (0.2.3): without it a forwarded copy that filled the device slots keeps them across every rotation and the owner's new pass is refused.
