const fs = require("fs"); const path = require("path"); const forge = require("node-forge"); const archiver = require("archiver"); const async = require("async"); const stream = require("stream"); const schema = require("./schema.js"); class Pass { constructor(options) { this.options = options; this.passTypes = ["boardingPass", "eventTicket", "coupon", "generic", "storeCard"]; this.overrides = this.options.overrides || {}; this.Certificates = {}; this.model = ""; } /** Compiles the pass @method generate @return {Promise} - A JSON structure containing the error or the stream of the generated pass. */ generate() { let manifest = {}; let archive = archiver("zip"); return new Promise((success, reject) => { let _gen = (() => { fs.readdir(this.model, (err, files) => { if (err) { return reject({ status: false, error: { message: "Model not found. Provide a valid one to continue." } }) } // list without dynamic components like manifest, signature or pass files (will be added later in the flow) and hidden files. let noDynList = removeHidden(files).filter(f => !/(manifest|signature|pass)/i.test(f)); if (!noDynList.length) { return reject({ status: false, error: { message: "Model provided matched but unitialized. Refer to https://apple.co/2IhJr0Q and documentation to fill the model correctly." } }); } // list without localization files (they will be added later in the flow) let bundleList = noDynList.filter(f => !f.includes(".lproj")); const L10N = { // localization folders only list: noDynList.filter(f => f.includes(".lproj")) }; /* * I may have (and I rathered) used async.concat to achieve this but it returns an * array of filenames ordered by folder, without any kind of folder indication. * So, the problem rises when I have to understand which is the first file of a * folder which is not the first one, as I don't know how many file there are in * a folder. * * Therefore, I generate a function for each localization (L10N) folder inside the * model. Each function will read the content of the folder and return an array of * the filenames inside that L10N folder. */ L10N.extractors = L10N.list.map(f => ((callback) => { let l10nPath = path.join(this.model, f); fs.readdir(l10nPath, function(err, list) { if (err) { return callback(err, null); } let filteredFiles = removeHidden(list); return callback(null, filteredFiles); }); })); // === flow definition === let _passExtractor = (passCallback => { fs.readFile(path.resolve(this.model, "pass.json"), {}, (err, passStructBuffer) => { if (err) { // Flow should never enter in there since pass.json existence-check is already done above. return passCallback({ status: false, error: { message: `Unable to read pass.json file @ ${this.model}` } }); } if (!this._validateType(passStructBuffer)) { return passCallback({ status: false, error: { message: `Unable to validate pass type or pass file is not a valid buffer. Check the syntax of your pass.json file or refer to https://apple.co/2Nvshvn to use a valid type.` } }); } this._patch(this._filterOptions(this.overrides), passStructBuffer) .then(function _afterJSONParse(passFileBuffer) { manifest["pass.json"] = forge.md.sha1.create().update(passFileBuffer.toString("binary")).digest().toHex(); archive.append(passFileBuffer, { name: "pass.json" }); // no errors happened return passCallback(null); }) .catch(err => { return passCallback({ status: false, error: { message: `Unable to read pass.json as buffer @ ${this.model}. Unable to continue.\n${err}`, ecode: 418 } }); }); }); }); let _addBuffers = ((err, modelBuffers) => { if (err) { return reject(err); } // I want to get an object containing each buffer associated with its own file name let modelFiles = Object.assign(...modelBuffers.map((buf, index) => ({ [bundleList[index]]: buf }))); async.eachOf(modelFiles, (fileBuffer, bufferKey, callback) => { let hashFlow = forge.md.sha1.create(); hashFlow.update(fileBuffer.toString("binary")); manifest[bufferKey] = hashFlow.digest().toHex().trim(); archive.file(path.resolve(this.model, bufferKey), { name: bufferKey }); return callback(); }, _finalize); }); let _finalize = (err => { if (err) { return reject({ status: false, error: { message: `Unable to compile manifest. ${err}`, ecode: 418 } }); } archive.append(JSON.stringify(manifest), { name: "manifest.json" }); let signatureBuffer = this._sign(manifest); archive.append(signatureBuffer, { name: "signature" }); let passStream = new stream.PassThrough(); archive.pipe(passStream); archive.finalize().then(function() { return success({ status: true, content: passStream, }); }); }); // === execution === async.parallel([_passExtractor, ...L10N.extractors], (err, listByFolder) => { if (err) { return reject(err); } // removing result of passExtractor, which is undefined. listByFolder.shift(); listByFolder.forEach((folder, index) => bundleList.push(...folder.map(f => path.join(L10N.list[index], f)))); let pathList = bundleList.map(f => path.resolve(this.model, f)); async.concat(pathList, fs.readFile, _addBuffers); }); }) }); this._parseSettings(this.options) .then(_gen) .catch(reject) }); } /** Checks if pass model type is one of the supported ones @method _validateType @params {Buffer} passBuffer - buffer of the pass structure content @returns {Boolean} - true if type is supported, false otherwise. */ _validateType(passBuffer) { try { let passFile = JSON.parse(passBuffer.toString("utf8")); let index = this.passTypes.findIndex(passType => passFile.hasOwnProperty(passType)); if (index == -1) { return false; } let type = this.passTypes[index]; return schema.isValid(passFile[type], schema.constants[(type === "boardingPass" ? "boarding" : "basic") + "Structure"]); } catch (e) { return false; } } /** Generates the PKCS #7 cryptografic signature for the manifest file. @method _sign @params {String|Object} manifest - Manifest content. @returns {Object} Promise */ _sign(manifest) { let signature = forge.pkcs7.createSignedData(); if (typeof manifest === "object") { signature.content = forge.util.createBuffer(JSON.stringify(manifest), "utf8"); } else if (typeof manifest === "string") { signature.content = manifest; } else { throw new Error(`Manifest content must be a string or an object. Unable to accept manifest of type ${typeof manifest}`); } signature.addCertificate(this.Certificates.wwdr); signature.addCertificate(this.Certificates.signerCert); signature.addSigner({ key: this.Certificates.signerKey, certificate: this.Certificates.signerCert, digestAlgorithm: forge.pki.oids.sha1, authenticatedAttributes: [{ type: forge.pki.oids.contentType, value: forge.pki.oids.data }, { type: forge.pki.oids.messageDigest, }, { // the value is autogenerated type: forge.pki.oids.signingTime, }] }); signature.sign(); /* * Signing creates in contentInfo a JSON object nested BER/TLV (X.690 standard) structure. * Each object represents a component of ASN.1 (Abstract Syntax Notation) * For a more complete reference, refer to: https://en.wikipedia.org/wiki/X.690#BER_encoding * * signature.contentInfo.type => SEQUENCE OF (16) * signature.contentInfo.value[0].type => OBJECT IDENTIFIER (6) * signature.contantInfo.value[1].type => END OF CONTENT (EOC - 0) * * EOC are only present only in constructed indefinite-length methods * Since `signature.contentInfo.value[1].value` contains an object whose value contains the content we passed, * we have to pop the whole object away to avoid signature content invalidation. * */ signature.contentInfo.value.pop(); // Converting the JSON Structure into a DER (which is a subset of BER), ASN.1 valid structure // Returning the buffer of the signature return Buffer.from(forge.asn1.toDer(signature.toAsn1()).getBytes(), "binary"); } /** Edits the buffer of pass.json based on the passed options. @method _patch @params {Object} options - options resulting from the filtering made by filterPassOptions function @params {Buffer} passBuffer - Buffer of the contents of pass.json @returns {Promise} - Edited pass.json buffer or Object containing error. */ _patch(options, passBuffer) { if (!options) { return Promise.resolve(passBuffer); } return new Promise((success, reject) => { try { let passFile = JSON.parse(passBuffer.toString("utf8")); // "barcodes" support got introduced in iOS 9 as array of barcode. // "barcode" is still used in older iOS versions if (passFile["barcode"]) { let barcode = passFile["barcode"]; if (!(barcode instanceof Object) || !schema.isValid(barcode, schema.constants.barcode) || !options.barcode && barcode.message === "") { console.log("\x1b[41m", `Barcode syntax of the chosen model (${path.parse(this.model).base}) is not correct or the override content was not provided got removed. Please refer to https://apple.co/2myAbst.`, "\x1b[0m"); delete passFile["barcode"]; } else { // options.barcode may not be defined passFile["barcode"].message = options.barcode || passFile["barcode"].message; } } else { console.log("\x1b[33m", `Your pass model (${path.parse(this.model).base}) is not compatible with iOS versions lower than iOS 9. Please refer to https://apple.co/2O5K65k to make it backward-compatible.`, "\x1b[0m"); } if (passFile["barcodes"] && passFile["barcodes"] instanceof Array) { if (!passFile["barcodes"].length) { console.log("\x1b[33m", `No barcodes support specified. The element got removed.`, "\x1b[0m"); delete passFile["barcodes"]; } passFile["barcodes"].forEach((b,i) => { if (!schema.isValid(b, schema.constants.barcode) && !!options.barcode && b.message !== "") { passFile["barcodes"].splice(i, 1); console.log("\x1b[41m", `Barcode @ index ${i} of the chosen model (${path.parse(this.model).base}) is not well-formed or have syntax errors and got removed. Please refer to https://apple.co/2myAbst.`, "\x1b[0m"); } else { // options.barcode may not be defined b.message = options.barcode || b.message; } }); } else { console.log("\x1b[33m", `Your pass model (${path.parse(this.model).base}) is not compatible with iOS versions greater than iOS 8. Refer to https://apple.co/2O5K65k to make it forward-compatible.`, "\x1b[0m"); } delete options["barcode"]; Object.assign(passFile, options); return success(Buffer.from(JSON.stringify(passFile))); } catch(e) { return reject(e); } }); } /** Filters the options received in the query from http request into supported options by Apple and this application. @method _filterOptions @params {Object} query - raw informations to be edited in the pass.json file from HTTP Request Params or Body @returns {Object} - filtered options based on above criterias. */ _filterOptions(query) { const supportedOptions = ["serialNumber", "userInfo", "expirationDate", "locations", "authenticationToken", "barcode"]; let options = {}; supportedOptions.forEach(function(key) { if (query[key]) { options[key] = query[key]; } }); return options; } /** Validates the contents of the passed options and assigns the contents to the right properties */ _parseSettings(options) { if (!schema.isValid(options, schema.constants.instance)) { return Promise.reject({ status: false, error: { message: "The options passed to Pass constructor does not meet the requirements. Refer to the documentation to compile them correctly." } }); } return new Promise((success, reject) => { if (!options.model || typeof options.model !== "string") { return reject({ status: false, error: { message: "A string model name must be provided in order to continue.", ecode: 418 } }); } this.model = path.resolve(options.model) + (!!options.model && !path.extname(options.model) ? ".pass" : ""); let certPaths = Object.keys(options.certificates) .filter(v => v !== "dir") .map((val) => path.resolve(typeof options.certificates[val] !== "object" ? options.certificates[val] : options.certificates[val]["keyFile"])); async.concat(certPaths, fs.readFile, (err, contents) => { if (err) { return reject(err); } contents.forEach(file => { let pem = this.__parsePEM(file, options.certificates.signerKey.passphrase); if (!pem) { return reject({ status: false, error: { message: "Invalid certificates got loaded. Please provide WWDR certificates and developer signer certificate and key (with passphrase).", ecode: 418 } }) } this.Certificates[pem.key] = pem.value; }); return success(); }); }); } __parsePEM(element, passphrase) { if (element.includes("PRIVATE KEY") && !!passphrase) { return { key: "signerKey", value: forge.pki.decryptRsaPrivateKey(element, String(passphrase)) }; } else if (element.includes("CERTIFICATE")) { // PEM-exported certificates with keys are in PKCS#12 format, hence they are composed of bags. return { key: element.includes("Bag Attributes") ? "signerCert" : "wwdr", value: forge.pki.certificateFromPem(element) }; } else { return {}; } } } /** Apply a filter to arg0 to remove hidden files names (starting with dot) @function removeHidden @params {[String]} from - list of file names @return {[String]} */ function removeHidden(from) { return from.filter(e => e.charAt(0) !== "."); } module.exports = { Pass };