@mmerterden/multi-agent-toolkit-mcp 3.7.1 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,432 @@
1
+ /**
2
+ * pass-kit - build, sign, validate and inspect Apple Wallet passes.
3
+ *
4
+ * GENERIC BY CONSTRUCTION. No airline, no brand, no project appears anywhere in
5
+ * this directory. All five of Apple's styles are first-class, the asset
6
+ * contract is a table per style, and every identifier - pass type, team,
7
+ * organization, colours, copy - is caller input. The only thing that is not
8
+ * input is the specification itself.
9
+ *
10
+ * NO SECRETS. A literal passphrase is not an accepted parameter. The caller
11
+ * names an environment variable or a keychain entry, and the value reaches
12
+ * openssl through `-passin env:` so it never appears in `ps`, never lands in a
13
+ * log, and is scrubbed out of any error text on the way back.
14
+ *
15
+ * OFFLINE. Nothing here contacts Apple or anything else.
16
+ *
17
+ * @module tools/pass-kit
18
+ */
19
+
20
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
21
+ import { basename, dirname, isAbsolute, join } from "node:path";
22
+ import { tmpdir } from "node:os";
23
+ import { validatePass } from "./validate.js";
24
+ import { STYLES, ASSETS, SCALES } from "./spec.js";
25
+ import {
26
+ ERROR_PREFIX,
27
+ listPassFiles,
28
+ buildManifest,
29
+ writeManifest,
30
+ resolvePassphrase,
31
+ inspectCertificate,
32
+ signManifest,
33
+ verifySignature,
34
+ archive,
35
+ unpack,
36
+ } from "./sign.js";
37
+
38
+ const CERT_ARGS = {
39
+ certificate: { type: "string", description: "Pass Type ID certificate, PEM" },
40
+ private_key: { type: "string", description: "Private key for that certificate, PEM" },
41
+ wwdr_certificate: { type: "string", description: "Apple WWDR intermediate certificate, PEM" },
42
+ passphrase_env: {
43
+ type: "string",
44
+ description:
45
+ "NAME of an environment variable holding the private-key passphrase. Not the passphrase itself - a literal is deliberately not accepted",
46
+ },
47
+ keychain_account: {
48
+ type: "string",
49
+ description: "Keychain account to read the passphrase from, instead of passphrase_env",
50
+ },
51
+ keychain_service: { type: "string", description: 'Keychain service (default "pkpass")' },
52
+ };
53
+
54
+ export const PASS_TOOLS = [
55
+ {
56
+ name: "pass_validate",
57
+ description:
58
+ "Check a pass against Apple's rules without building or signing anything: required keys, the one style dictionary, field shapes, colours, barcodes, the per-style image contract, localization consistency, and semantic tags. Grades findings by consequence - an error is a pass Wallet refuses, a warning is a pass Wallet accepts while quietly doing less than intended. One unknown PKPassengerCapability value is the canonical example: no error anywhere, and the enhanced layout silently falls back.",
59
+ inputSchema: {
60
+ type: "object",
61
+ properties: {
62
+ pass_json: { type: "string", description: "Path to a pass.json" },
63
+ pass_dir: { type: "string", description: "Path to a .pass directory - checks its images and localizations too" },
64
+ pkpass: { type: "string", description: "Path to a built .pkpass - unpacked and checked, signature included" },
65
+ },
66
+ },
67
+ },
68
+ {
69
+ name: "pass_build",
70
+ description:
71
+ "Assemble, sign and archive a .pkpass. Takes a pass.json and an asset directory, writes the manifest (SHA-1 per file, as Apple specifies), signs it with openssl into a detached PKCS#7 signature, and zips the result. Validates first and refuses on an error-grade finding unless told otherwise. Offline, deterministic, and never accepts a literal passphrase.",
72
+ inputSchema: {
73
+ type: "object",
74
+ properties: {
75
+ pass_json: { type: "string", description: "Path to the pass.json to build from" },
76
+ assets_dir: { type: "string", description: "Directory holding the images and any xx.lproj folders" },
77
+ output: { type: "string", description: "Path to write the .pkpass to" },
78
+ ...CERT_ARGS,
79
+ overrides: {
80
+ type: "object",
81
+ description:
82
+ "Top-level keys to merge into pass.json before building - serialNumber, relevantDate, barcodes and so on. The file on disk is not modified",
83
+ },
84
+ allow_errors: {
85
+ type: "boolean",
86
+ description: "Build even when validation reports errors (default false)",
87
+ },
88
+ },
89
+ required: ["pass_json", "assets_dir", "output"],
90
+ },
91
+ },
92
+ {
93
+ name: "pass_inspect",
94
+ description:
95
+ "Open an existing .pkpass and report what is actually in it: style, top-level keys, semantic tags, images and their scales, localizations, whether the manifest matches the files, and the certificate chain that signed it. Read-only, and it never needs the private key.",
96
+ inputSchema: {
97
+ type: "object",
98
+ properties: {
99
+ pkpass: { type: "string", description: "Path to the .pkpass to open" },
100
+ show_pass_json: { type: "boolean", description: "Include the full pass.json in the result" },
101
+ },
102
+ required: ["pkpass"],
103
+ },
104
+ },
105
+ {
106
+ name: "pass_certificates",
107
+ description:
108
+ "Report the signing material: which certificate this is, its pass type and team identifier, when it expires and whether it already has. An expired Pass Type ID certificate still builds, still signs and still verifies - Wallet is the first thing that refuses it, and it does not say why. Never reads or reports key material.",
109
+ inputSchema: {
110
+ type: "object",
111
+ properties: {
112
+ certificate: { type: "string", description: "Pass Type ID certificate, PEM" },
113
+ wwdr_certificate: { type: "string", description: "Apple WWDR intermediate certificate, PEM" },
114
+ passphrase_env: { type: "string", description: "Name of an env var to check is set (its value is never read out)" },
115
+ keychain_account: { type: "string", description: "Keychain account to check exists" },
116
+ keychain_service: { type: "string", description: 'Keychain service (default "pkpass")' },
117
+ },
118
+ required: ["certificate"],
119
+ },
120
+ },
121
+ ];
122
+
123
+ export const PASS_READ_ONLY = ["pass_validate", "pass_inspect", "pass_certificates"];
124
+
125
+ export const PASS_OUTPUT_SCHEMAS = {
126
+ pass_validate: {
127
+ type: "object",
128
+ properties: {
129
+ style: { type: "string" },
130
+ ok: { type: "boolean" },
131
+ errors: { type: "array", items: { type: "object" } },
132
+ warnings: { type: "array", items: { type: "object" } },
133
+ notes: { type: "array", items: { type: "object" } },
134
+ checked: { type: "object" },
135
+ },
136
+ required: ["ok", "errors", "warnings", "notes"],
137
+ },
138
+ pass_certificates: {
139
+ type: "object",
140
+ properties: {
141
+ certificate: { type: "object" },
142
+ wwdr: { type: "object" },
143
+ passphrase: { type: "object" },
144
+ usable: { type: "boolean" },
145
+ },
146
+ required: ["certificate", "usable"],
147
+ },
148
+ };
149
+
150
+ function requireAbs(p, label) {
151
+ if (!p) return `${label} is required`;
152
+ if (!isAbsolute(p)) return `${label} must be an absolute path, got "${p}"`;
153
+ if (!existsSync(p)) return `no such ${label}: ${p}`;
154
+ return null;
155
+ }
156
+
157
+ function readJson(p) {
158
+ try {
159
+ return { value: JSON.parse(readFileSync(p, "utf8")) };
160
+ } catch (e) {
161
+ return { error: `${ERROR_PREFIX}${p} is not readable JSON: ${e.message}` };
162
+ }
163
+ }
164
+
165
+ function validateTool(args) {
166
+ if (args.pkpass) {
167
+ const bad = requireAbs(args.pkpass, "pkpass");
168
+ if (bad) return `${ERROR_PREFIX}${bad}`;
169
+ const un = unpack(args.pkpass);
170
+ if (un.error) return un.error;
171
+ try {
172
+ const passPath = join(un.dir, "pass.json");
173
+ if (!existsSync(passPath)) return `${ERROR_PREFIX}${args.pkpass} contains no pass.json`;
174
+ const doc = readJson(passPath);
175
+ if (doc.error) return doc.error;
176
+ const files = listPassFiles(un.dir);
177
+ const report = validatePass(doc.value, files);
178
+ const sig = existsSync(join(un.dir, "signature"))
179
+ ? verifySignature({
180
+ signaturePath: join(un.dir, "signature"),
181
+ manifestPath: join(un.dir, "manifest.json"),
182
+ })
183
+ : { valid: false, reason: "no signature file" };
184
+ const manifestCheck = checkManifest(un.dir, files);
185
+ return {
186
+ ...report,
187
+ checked: { source: args.pkpass, files: files.length, signature: sig, manifest: manifestCheck },
188
+ };
189
+ } finally {
190
+ rmSync(un.dir, { recursive: true, force: true });
191
+ }
192
+ }
193
+
194
+ const dir = args.pass_dir;
195
+ if (dir) {
196
+ const bad = requireAbs(dir, "pass_dir");
197
+ if (bad) return `${ERROR_PREFIX}${bad}`;
198
+ const passPath = join(dir, "pass.json");
199
+ if (!existsSync(passPath)) return `${ERROR_PREFIX}${dir} contains no pass.json`;
200
+ const doc = readJson(passPath);
201
+ if (doc.error) return doc.error;
202
+ const files = listPassFiles(dir);
203
+ return { ...validatePass(doc.value, files), checked: { source: dir, files: files.length } };
204
+ }
205
+
206
+ const p = args.pass_json;
207
+ const bad = requireAbs(p, "pass_json");
208
+ if (bad) return `${ERROR_PREFIX}${bad}`;
209
+ const doc = readJson(p);
210
+ if (doc.error) return doc.error;
211
+ const report = validatePass(doc.value, []);
212
+ // Asset findings against an empty file list would be noise, not findings.
213
+ return {
214
+ ...report,
215
+ errors: report.errors.filter((e) => e.kind !== "asset"),
216
+ checked: { source: p, files: 0, note: "pass.json only - give pass_dir or pkpass to check images too" },
217
+ };
218
+ }
219
+
220
+ function checkManifest(dir, files) {
221
+ const manifestPath = join(dir, "manifest.json");
222
+ if (!existsSync(manifestPath)) return { present: false };
223
+ const doc = readJson(manifestPath);
224
+ if (doc.error) return { present: true, valid: false, reason: "unreadable" };
225
+ const expected = buildManifest(dir, files);
226
+ const missing = Object.keys(expected).filter((f) => !(f in doc.value));
227
+ const extra = Object.keys(doc.value).filter((f) => !(f in expected));
228
+ const mismatched = Object.keys(expected).filter((f) => f in doc.value && doc.value[f] !== expected[f]);
229
+ return {
230
+ present: true,
231
+ valid: missing.length === 0 && extra.length === 0 && mismatched.length === 0,
232
+ entries: Object.keys(doc.value).length,
233
+ ...(missing.length ? { missing } : {}),
234
+ ...(extra.length ? { extra } : {}),
235
+ ...(mismatched.length ? { mismatched } : {}),
236
+ };
237
+ }
238
+
239
+ function buildTool(args) {
240
+ for (const [p, label] of [
241
+ [args.pass_json, "pass_json"],
242
+ [args.assets_dir, "assets_dir"],
243
+ [args.certificate, "certificate"],
244
+ [args.private_key, "private_key"],
245
+ [args.wwdr_certificate, "wwdr_certificate"],
246
+ ]) {
247
+ const bad = requireAbs(p, label);
248
+ if (bad) return `${ERROR_PREFIX}${bad}`;
249
+ }
250
+ if (!args.output || !isAbsolute(args.output)) {
251
+ return `${ERROR_PREFIX}output must be an absolute path`;
252
+ }
253
+
254
+ const pass = readJson(args.pass_json);
255
+ if (pass.error) return pass.error;
256
+ const doc = { ...pass.value, ...(args.overrides || {}) };
257
+
258
+ const cert = inspectCertificate(args.certificate);
259
+ if (cert.expired) {
260
+ return `${ERROR_PREFIX}the Pass Type ID certificate expired on ${cert.notAfter}. A pass signed with it builds, signs and verifies, and Wallet then refuses it without saying why. Renew it before building.`;
261
+ }
262
+
263
+ const secret = resolvePassphrase(args);
264
+ if (secret.error) return secret.error;
265
+
266
+ const staging = mkdtempSync(join(tmpdir(), "pass-build-"));
267
+ try {
268
+ cpSync(args.assets_dir, staging, { recursive: true });
269
+ rmSync(join(staging, "pass.json"), { force: true });
270
+ rmSync(join(staging, "manifest.json"), { force: true });
271
+ rmSync(join(staging, "signature"), { force: true });
272
+ writeFileSync(join(staging, "pass.json"), JSON.stringify(doc, null, 2));
273
+
274
+ const files = listPassFiles(staging);
275
+ const report = validatePass(doc, files);
276
+ if (!report.ok && !args.allow_errors) {
277
+ return {
278
+ built: false,
279
+ reason: "validation failed - pass allow_errors: true to build anyway",
280
+ ...report,
281
+ };
282
+ }
283
+
284
+ const manifest = buildManifest(staging, files);
285
+ const manifestPath = writeManifest(staging, manifest);
286
+ const signed = signManifest({
287
+ manifestPath,
288
+ signaturePath: join(staging, "signature"),
289
+ certificate: args.certificate,
290
+ privateKey: args.private_key,
291
+ wwdr: args.wwdr_certificate,
292
+ passphrase: secret.value,
293
+ });
294
+ if (signed.error) return signed.error;
295
+
296
+ mkdirSync(dirname(args.output), { recursive: true });
297
+ rmSync(args.output, { force: true });
298
+ const zipped = archive(staging, args.output);
299
+ if (zipped.error) return zipped.error;
300
+
301
+ return {
302
+ built: true,
303
+ output: args.output,
304
+ bytes: zipped.bytes,
305
+ style: report.style,
306
+ files: files.length + 2,
307
+ manifestEntries: Object.keys(manifest).length,
308
+ passphraseSource: secret.source,
309
+ certificate: { passTypeIdentifier: cert.passTypeIdentifier, teamIdentifier: cert.teamIdentifier, notAfter: cert.notAfter, daysLeft: cert.daysLeft },
310
+ warnings: report.warnings,
311
+ notes: report.notes,
312
+ ...(report.ok ? {} : { errors: report.errors, builtWithErrors: true }),
313
+ };
314
+ } catch (e) {
315
+ return `${ERROR_PREFIX}build failed: ${e.message}`;
316
+ } finally {
317
+ rmSync(staging, { recursive: true, force: true });
318
+ }
319
+ }
320
+
321
+ function inspectTool(args) {
322
+ const bad = requireAbs(args.pkpass, "pkpass");
323
+ if (bad) return `${ERROR_PREFIX}${bad}`;
324
+ const un = unpack(args.pkpass);
325
+ if (un.error) return un.error;
326
+ try {
327
+ const passPath = join(un.dir, "pass.json");
328
+ if (!existsSync(passPath)) return `${ERROR_PREFIX}${args.pkpass} contains no pass.json`;
329
+ const doc = readJson(passPath);
330
+ if (doc.error) return doc.error;
331
+ const pass = doc.value;
332
+ const files = listPassFiles(un.dir);
333
+ const style = STYLES.find((s) => pass[s] !== undefined) || null;
334
+
335
+ const images = {};
336
+ for (const f of files) {
337
+ const m = /^([a-zA-Z]+)(@2x|@3x)?\.png$/.exec(f.split("/").pop() || "");
338
+ if (!m) continue;
339
+ images[m[1]] = images[m[1]] || [];
340
+ images[m[1]].push(m[2] || "1x");
341
+ }
342
+ const localizations = [
343
+ ...new Set(files.filter((f) => f.includes(".lproj/")).map((f) => f.split(".lproj/")[0].split("/").pop())),
344
+ ];
345
+
346
+ const sig = existsSync(join(un.dir, "signature"))
347
+ ? verifySignature({ signaturePath: join(un.dir, "signature"), manifestPath: join(un.dir, "manifest.json") })
348
+ : { valid: false, reason: "no signature file" };
349
+
350
+ return {
351
+ pkpass: args.pkpass,
352
+ bytes: statSync(args.pkpass).size,
353
+ style,
354
+ passTypeIdentifier: pass.passTypeIdentifier,
355
+ teamIdentifier: pass.teamIdentifier,
356
+ organizationName: pass.organizationName,
357
+ serialNumber: pass.serialNumber,
358
+ formatVersion: pass.formatVersion,
359
+ topLevelKeys: Object.keys(pass).sort(),
360
+ semanticTags: Object.keys(pass.semantics || {}).sort(),
361
+ barcodes: (pass.barcodes || []).map((b) => b.format),
362
+ updates: pass.webServiceURL ? { webServiceURL: pass.webServiceURL, hasAuthenticationToken: Boolean(pass.authenticationToken) } : null,
363
+ images,
364
+ localizations,
365
+ files: files.length,
366
+ manifest: checkManifest(un.dir, files),
367
+ signature: sig,
368
+ ...(args.show_pass_json ? { passJson: pass } : {}),
369
+ };
370
+ } finally {
371
+ rmSync(un.dir, { recursive: true, force: true });
372
+ }
373
+ }
374
+
375
+ function certificatesTool(args) {
376
+ const bad = requireAbs(args.certificate, "certificate");
377
+ if (bad) return `${ERROR_PREFIX}${bad}`;
378
+ const cert = inspectCertificate(args.certificate);
379
+ const wwdr = args.wwdr_certificate ? inspectCertificate(args.wwdr_certificate) : null;
380
+
381
+ let passphrase = { configured: false, note: "give passphrase_env or keychain_account to check one" };
382
+ if (args.passphrase_env || args.keychain_account) {
383
+ const r = resolvePassphrase(args);
384
+ // The VALUE is never reported, only whether one was found.
385
+ passphrase = r.error
386
+ ? { configured: false, reason: r.error.replace(ERROR_PREFIX, "") }
387
+ : { configured: true, source: r.source };
388
+ }
389
+
390
+ const usable = Boolean(cert && !cert.error && cert.expired === false);
391
+ const advice = [];
392
+ if (cert.expired) {
393
+ advice.push(
394
+ `The certificate expired on ${cert.notAfter}. A pass signed with it builds, signs and verifies; Wallet refuses it and says nothing. Renew it at developer.apple.com.`,
395
+ );
396
+ } else if (cert.daysLeft !== null && cert.daysLeft < 30) {
397
+ advice.push(`The certificate expires in ${cert.daysLeft} day(s).`);
398
+ }
399
+ if (!wwdr) advice.push("Give wwdr_certificate to check the intermediate too; signing needs it.");
400
+ if (!passphrase.configured) {
401
+ advice.push(
402
+ "No passphrase source configured. Name an environment variable (passphrase_env) or store one in the keychain; a literal passphrase is deliberately not an accepted input.",
403
+ );
404
+ }
405
+
406
+ return { certificate: cert, wwdr, passphrase, usable, advice };
407
+ }
408
+
409
+ /**
410
+ * @param {string} name
411
+ * @param {object} args
412
+ */
413
+ export async function handlePass(name, args = {}) {
414
+ try {
415
+ switch (name) {
416
+ case "pass_validate":
417
+ return validateTool(args);
418
+ case "pass_build":
419
+ return buildTool(args);
420
+ case "pass_inspect":
421
+ return inspectTool(args);
422
+ case "pass_certificates":
423
+ return certificatesTool(args);
424
+ default:
425
+ return null;
426
+ }
427
+ } catch (e) {
428
+ return `${ERROR_PREFIX}${name}: ${e?.message || String(e)}`;
429
+ }
430
+ }
431
+
432
+ export { STYLES, ASSETS, SCALES };
@@ -0,0 +1,255 @@
1
+ /**
2
+ * sign.js - manifest, signature, archive. And where the passphrase comes from.
3
+ *
4
+ * NO SHELL. Every child is `execFileSync(bin, [argv])`. The passphrase reaches
5
+ * openssl through `-passin env:VAR` and never as an argument, because an
6
+ * argument is world-readable in `ps` for as long as the process runs. It is
7
+ * never written to disk, never logged, and scrubbed out of any error text
8
+ * before that text is returned.
9
+ *
10
+ * WHERE IT COMES FROM, in order: an explicit `passphrase_env` naming an
11
+ * environment variable, then a keychain entry, then nothing. A literal
12
+ * passphrase is not an accepted input at all - a tool that takes one invites a
13
+ * caller to put it in a config file, and that is how the project this replaces
14
+ * ended up publishing its own in a README three times.
15
+ *
16
+ * @module tools/pass-kit/sign
17
+ */
18
+
19
+ import { createHash } from "node:crypto";
20
+ import { execFileSync } from "node:child_process";
21
+ import { readdirSync, readFileSync, statSync, writeFileSync, mkdtempSync } from "node:fs";
22
+ import { join, relative, sep } from "node:path";
23
+ import { tmpdir } from "node:os";
24
+
25
+ const ERROR_PREFIX = "ERROR: ";
26
+
27
+ /** Files the manifest never covers, because they are produced from it. */
28
+ const NOT_HASHED = new Set(["manifest.json", "signature"]);
29
+
30
+ /**
31
+ * Every file in a `.pass` directory, as manifest-relative names.
32
+ * Localization folders are included with their `xx.lproj/` prefix.
33
+ */
34
+ export function listPassFiles(dir) {
35
+ const out = [];
36
+ const walk = (d) => {
37
+ for (const e of readdirSync(d, { withFileTypes: true })) {
38
+ if (e.name.startsWith(".")) continue;
39
+ const p = join(d, e.name);
40
+ if (e.isDirectory()) walk(p);
41
+ else out.push(relative(dir, p).split(sep).join("/"));
42
+ }
43
+ };
44
+ walk(dir);
45
+ return out.sort();
46
+ }
47
+
48
+ /**
49
+ * SHA-1 of every file, which is what Apple specifies - not a choice.
50
+ */
51
+ export function buildManifest(dir, files) {
52
+ const manifest = {};
53
+ for (const f of files) {
54
+ if (NOT_HASHED.has(f)) continue;
55
+ manifest[f] = createHash("sha1").update(readFileSync(join(dir, f))).digest("hex");
56
+ }
57
+ return manifest;
58
+ }
59
+
60
+ /**
61
+ * Read a passphrase without it ever becoming an argument or a file.
62
+ * @returns {{value: string}|{error: string}}
63
+ */
64
+ export function resolvePassphrase({ passphrase_env, keychain_account, keychain_service }) {
65
+ if (passphrase_env) {
66
+ const v = process.env[passphrase_env];
67
+ if (!v) {
68
+ return {
69
+ error: `${ERROR_PREFIX}the environment variable ${passphrase_env} is unset or empty`,
70
+ };
71
+ }
72
+ return { value: v, source: `env:${passphrase_env}` };
73
+ }
74
+ if (keychain_account) {
75
+ try {
76
+ const v = execFileSync(
77
+ "/usr/bin/security",
78
+ [
79
+ "find-generic-password",
80
+ "-a",
81
+ keychain_account,
82
+ "-s",
83
+ keychain_service || "pkpass",
84
+ "-w",
85
+ ],
86
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10000 },
87
+ ).trim();
88
+ if (v) return { value: v, source: `keychain:${keychain_account}` };
89
+ } catch {
90
+ /* fall through to the error below */
91
+ }
92
+ return {
93
+ error: `${ERROR_PREFIX}no keychain entry for account "${keychain_account}" in service "${keychain_service || "pkpass"}". Store one with: security add-generic-password -a ${keychain_account} -s ${keychain_service || "pkpass"} -w '<passphrase>'`,
94
+ };
95
+ }
96
+ return {
97
+ error: `${ERROR_PREFIX}no passphrase source. Give passphrase_env (the NAME of an environment variable) or keychain_account. A literal passphrase is deliberately not accepted.`,
98
+ };
99
+ }
100
+
101
+ /** Certificate metadata, without ever touching the private key. */
102
+ export function inspectCertificate(certPath) {
103
+ try {
104
+ const subject = openssl(["x509", "-in", certPath, "-noout", "-subject"]).trim();
105
+ const dates = openssl(["x509", "-in", certPath, "-noout", "-dates"]).trim();
106
+ const notAfter = /notAfter=(.+)/.exec(dates)?.[1]?.trim() || null;
107
+ const notBefore = /notBefore=(.+)/.exec(dates)?.[1]?.trim() || null;
108
+ const expiresAt = notAfter ? new Date(notAfter) : null;
109
+ const expired = expiresAt ? expiresAt.getTime() < Date.now() : null;
110
+ const uid = /UID\s*=\s*([^,/]+)/.exec(subject)?.[1]?.trim() || null;
111
+ const ou = /OU\s*=\s*([^,/]+)/.exec(subject)?.[1]?.trim() || null;
112
+ return {
113
+ path: certPath,
114
+ passTypeIdentifier: uid,
115
+ teamIdentifier: ou,
116
+ notBefore,
117
+ notAfter,
118
+ expired,
119
+ daysLeft: expiresAt ? Math.floor((expiresAt.getTime() - Date.now()) / 86400000) : null,
120
+ };
121
+ } catch (e) {
122
+ return { path: certPath, error: `cannot read: ${short(e)}` };
123
+ }
124
+ }
125
+
126
+ function openssl(args, opts = {}) {
127
+ return execFileSync("openssl", args, {
128
+ encoding: "utf8",
129
+ stdio: ["ignore", "pipe", "pipe"],
130
+ timeout: 30000,
131
+ ...opts,
132
+ });
133
+ }
134
+
135
+ function short(e) {
136
+ return String(e?.stderr || e?.message || e)
137
+ .trim()
138
+ .slice(0, 300);
139
+ }
140
+
141
+ /**
142
+ * Detached PKCS#7 signature over the manifest.
143
+ *
144
+ * @returns {{ok: true}|{error: string}}
145
+ */
146
+ export function signManifest({ manifestPath, signaturePath, certificate, privateKey, wwdr, passphrase }) {
147
+ const env = { ...process.env, PKPASS_KEY_PASSPHRASE: passphrase };
148
+ try {
149
+ execFileSync(
150
+ "openssl",
151
+ [
152
+ "smime",
153
+ "-binary",
154
+ "-sign",
155
+ "-certfile",
156
+ wwdr,
157
+ "-signer",
158
+ certificate,
159
+ "-inkey",
160
+ privateKey,
161
+ "-in",
162
+ manifestPath,
163
+ "-out",
164
+ signaturePath,
165
+ "-passin",
166
+ "env:PKPASS_KEY_PASSPHRASE",
167
+ "-outform",
168
+ "DER",
169
+ ],
170
+ { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60000 },
171
+ );
172
+ return { ok: true };
173
+ } catch (e) {
174
+ return { error: `${ERROR_PREFIX}signing failed: ${scrub(short(e), passphrase)}` };
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Verify a signature against its manifest and report the chain.
180
+ *
181
+ * Deliberately does NOT assert trust: the WWDR chain is not in the system store
182
+ * on every machine, and a verification that fails for that reason would read as
183
+ * a broken pass. `-noverify` checks the signature itself; the chain is reported
184
+ * so a human can see what signed it.
185
+ */
186
+ export function verifySignature({ signaturePath, manifestPath }) {
187
+ try {
188
+ openssl([
189
+ "smime",
190
+ "-verify",
191
+ "-binary",
192
+ "-inform",
193
+ "DER",
194
+ "-in",
195
+ signaturePath,
196
+ "-content",
197
+ manifestPath,
198
+ "-noverify",
199
+ "-out",
200
+ "/dev/null",
201
+ ]);
202
+ const certs = openssl(["pkcs7", "-inform", "DER", "-in", signaturePath, "-print_certs", "-noout"]);
203
+ const chain = certs
204
+ .split("\n")
205
+ .map((l) => l.trim())
206
+ .filter((l) => l.startsWith("subject="))
207
+ .map((l) => l.replace(/^subject=\s*/, ""));
208
+ return { valid: true, chain };
209
+ } catch (e) {
210
+ return { valid: false, reason: short(e) };
211
+ }
212
+ }
213
+
214
+ /** Zip a directory into a .pkpass, with the entries at the archive root. */
215
+ export function archive(passDir, outPath) {
216
+ try {
217
+ execFileSync("/usr/bin/zip", ["-q", "-r", "-X", outPath, "."], {
218
+ cwd: passDir,
219
+ encoding: "utf8",
220
+ stdio: ["ignore", "pipe", "pipe"],
221
+ timeout: 60000,
222
+ });
223
+ return { ok: true, bytes: statSync(outPath).size };
224
+ } catch (e) {
225
+ return { error: `${ERROR_PREFIX}archiving failed: ${short(e)}` };
226
+ }
227
+ }
228
+
229
+ /** Unpack a .pkpass into a temporary directory for inspection. */
230
+ export function unpack(pkpassPath) {
231
+ const dir = mkdtempSync(join(tmpdir(), "pkpass-"));
232
+ try {
233
+ execFileSync("/usr/bin/unzip", ["-q", "-o", pkpassPath, "-d", dir], {
234
+ encoding: "utf8",
235
+ stdio: ["ignore", "pipe", "pipe"],
236
+ timeout: 60000,
237
+ });
238
+ return { dir };
239
+ } catch (e) {
240
+ return { error: `${ERROR_PREFIX}cannot unpack ${pkpassPath}: ${short(e)}` };
241
+ }
242
+ }
243
+
244
+ export function writeManifest(dir, manifest) {
245
+ const p = join(dir, "manifest.json");
246
+ writeFileSync(p, JSON.stringify(manifest, null, 2));
247
+ return p;
248
+ }
249
+
250
+ function scrub(text, secret) {
251
+ if (!secret) return text;
252
+ return text.split(secret).join("***");
253
+ }
254
+
255
+ export { ERROR_PREFIX };