UNPKG

@walletpass/pass-js

Version:

Apple Wallet Pass generating and pushing updates from Node.js

237 lines 11.2 kB
// SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2017-2026 Konstantin Vyatkin <tino@vtkn.io> // Passbook templates import * as http2 from 'node:http2'; import { createPrivateKey, X509Certificate } from 'node:crypto'; import { join } from 'node:path'; import { readFile, readdir } from 'node:fs/promises'; import { Pass } from './pass.js'; import { PASS_STYLES } from './constants.js'; import { PassBase } from './lib/base-pass.js'; import { readZip } from './lib/zip.js'; import { stripJsonComments } from './lib/strip-json-comments.js'; import { parsePersonalizationBuffer, } from './lib/personalization.js'; const { HTTP2_HEADER_METHOD, HTTP2_HEADER_PATH, NGHTTP2_CANCEL, HTTP2_METHOD_POST, } = http2.constants; export class Template extends PassBase { key; certificate; apn; constructor(style, fields = {}, images, localization, options, personalization) { super(fields, images, localization, options, personalization); if (style) { if (!PASS_STYLES.has(style)) throw new TypeError(`Unsupported pass style ${style}`); this.style = style; } } /** * Load a Template, images, and key from a trusted directory on disk. * * Do not pass attacker-controlled template folders. Loading untrusted * pass bundles can force excessive memory allocation and may crash or stall * the process. */ static async load(folderPath, keyPassword, options) { const entries = await readdir(folderPath, { withFileTypes: true }); let template; if (entries.find(entry => entry.isFile() && entry.name === 'pass.json')) { const passJsonPath = join(folderPath, 'pass.json'); const jsonContent = await readFile(passJsonPath, 'utf8'); const passJson = JSON.parse(stripJsonComments(jsonContent)); let type; for (const t of PASS_STYLES) { if (t in passJson) { type = t; break; } } if (!type) throw new TypeError('Unknown pass style!'); template = new Template(type, passJson, undefined, undefined, options); } else { template = createDefaultTemplate(options); } const { passTypeIdentifier } = template; const keyName = passTypeIdentifier ? `${passTypeIdentifier.replace(/^pass\./, '')}.pem` : undefined; const entriesLoader = []; for (const entry of entries) { if (entry.isDirectory()) { const test = /(?<lang>[-A-Z_a-z]+)\.lproj/.exec(entry.name); if (!test?.groups?.['lang']) continue; const lang = test.groups['lang']; const currentPath = join(folderPath, entry.name); const localizations = await readdir(currentPath, { withFileTypes: true, }); if (localizations.find(f => f.isFile() && f.name === 'pass.strings')) entriesLoader.push(template.localization.addFile(lang, join(currentPath, 'pass.strings'))); for (const f of localizations) { const img = template.images.parseFilename(f.name); if (img) entriesLoader.push(template.images.add(img.imageType, join(currentPath, f.name), img.density, lang, options?.disableImageCheck)); } } else { if (entry.name === 'personalization.json') { entriesLoader.push(readFile(join(folderPath, entry.name)).then(buffer => { template.personalization = parsePersonalizationBuffer(buffer); })); continue; } if (entry.name === keyName) { entriesLoader.push(template.loadCertificate(join(folderPath, keyName), keyPassword)); continue; } const img = template.images.parseFilename(entry.name); if (img) entriesLoader.push(template.images.add(img.imageType, join(folderPath, entry.name), img.density, undefined, options?.disableImageCheck)); } } await Promise.all(entriesLoader); return template; } /** * Reconstruct a Template from a trusted pre-zipped buffer (e.g. a .pkpass * fetched from S3). Reads pass.json, images, and localization strings out of * the bundle. * * Do not pass attacker-controlled ZIP or .pkpass buffers. Loading untrusted * pass bundles can force excessive memory allocation and may crash or stall * the process. */ static async fromBuffer(buffer, options) { const zip = readZip(buffer); if (zip.entries.length < 1) throw new TypeError(`Provided ZIP buffer contains no entries`); let template = createDefaultTemplate(options); let foundPassJson = false; for (const entry of zip.entries) { if (entry.filename.endsWith('/')) continue; if (/(?:^|\/)pass\.json$/i.test(entry.filename)) { if (foundPassJson) throw new TypeError(`Archive contains more than one pass.json - found ${entry.filename}`); foundPassJson = true; const buf = zip.getBuffer(entry); const passJSON = JSON.parse(stripJsonComments(buf.toString('utf8'))); template = new Template(undefined, passJSON, template.images, template.localization, options, template.personalization); } else if (/(?:^|\/)personalization\.json$/i.test(entry.filename)) { template.personalization = parsePersonalizationBuffer(zip.getBuffer(entry)); } else { const img = template.images.parseFilename(entry.filename); if (img) { const imgBuffer = zip.getBuffer(entry); await template.images.add(img.imageType, imgBuffer, img.density, img.lang, options?.disableImageCheck); } else { const test = /(^|\/)(?<lang>[-_a-z]+)\.lproj\/pass\.strings$/i.exec(entry.filename); if (test?.groups?.['lang']) { const buf = zip.getBuffer(entry); await template.localization.addFromBuffer(test.groups['lang'], buf); } } } } if (!foundPassJson) throw new TypeError(`Archive does not contain a pass.json`); return template; } // Accepts a PEM-encoded RSA private key. If the key is encrypted, supply // a password. Stored as a node:crypto KeyObject for use at sign time. // // Apple Pass Type ID certificates are always issued as RSA; `signManifest` // hard-codes rsaEncryption as the CMS signature algorithm. Reject other // key types here so the failure is loud at load time instead of producing // a signature that Wallet silently rejects. setPrivateKey(pem, password) { let key; try { key = createPrivateKey({ key: pem, format: 'pem', passphrase: password }); } catch (err) { throw new Error('Failed to decode provided private key. Invalid password?', { cause: err }); } if (key.asymmetricKeyType !== 'rsa') { throw new TypeError(`Pass Type ID key must be RSA, got ${key.asymmetricKeyType ?? 'unknown'}`); } this.key = key; } // Accepts a PEM that contains the signing certificate, and optionally // a private key (as exported from `openssl pkcs12 -in ... -out ... -nodes` // or similar). Private-key extraction mirrors the previous node-forge // behaviour: any "-----BEGIN …KEY-----" block is used. setCertificate(pem, password) { const certMatch = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/); if (!certMatch) throw new Error('Failed to decode provided certificate: no PEM cert block found'); // Validate by parsing — throws if malformed. // eslint-disable-next-line no-new new X509Certificate(certMatch[0]); this.certificate = certMatch[0]; const keyMatch = pem.match(/-----BEGIN (?:ENCRYPTED |RSA |EC )?PRIVATE KEY-----[\s\S]+?-----END (?:ENCRYPTED |RSA |EC )?PRIVATE KEY-----/); if (keyMatch) this.setPrivateKey(keyMatch[0], password); } async loadCertificate(signerPemFile, password) { const signerCertData = await readFile(signerPemFile, 'utf8'); this.setCertificate(signerCertData, password); } // Push an update notification to a device via Apple's APN service. // See Apple docs: Updating a Pass. async pushUpdates(pushToken) { if (!this.apn || this.apn.destroyed) { await new Promise((resolve, reject) => { if (!this.key) throw new ReferenceError(`Set private key before trying to push pass updates`); if (!this.certificate) throw new ReferenceError(`Set pass certificate before trying to push pass updates`); const apn = http2.connect('https://api.push.apple.com:443', { key: this.key.export({ type: 'pkcs8', format: 'pem' }), cert: this.certificate, }); apn.unref(); apn .once('goaway', () => { if (this.apn && !this.apn.destroyed) this.apn.destroy(); }) .once('error', reject) .once('connect', () => { if (apn.destroyed) { reject(new Error('APN was destroyed before connecting')); return; } this.apn = apn; resolve(); }); }); } return new Promise((resolve, reject) => { if (!this.apn || this.apn.destroyed) throw new Error('APN was destroyed before connecting'); const req = this.apn.request({ [HTTP2_HEADER_METHOD]: HTTP2_METHOD_POST, [HTTP2_HEADER_PATH]: `/3/device/${encodeURIComponent(pushToken)}`, }); req.setTimeout(5000, () => { req.close(NGHTTP2_CANCEL, () => reject(new Error(`http2: timeout connecting to api.push.apple.com`))); }); req.once('error', reject); req.once('response', resolve); req.end('{}'); }); } createPass(fields = {}) { return new Pass(this, { ...this.fields, ...fields }, this.images, this.localization, this.options, this.personalization); } } function createDefaultTemplate(options) { return new Template(undefined, {}, undefined, undefined, options); } //# sourceMappingURL=template.js.map