@nexart/cli 0.12.2 → 0.15.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.
package/dist/index.js CHANGED
@@ -1345,7 +1345,7 @@ export async function exportVerificationCommand(file, opts, stdinReader = readSt
1345
1345
  * NOTE: named `verify-bundle` (not `verify`) because `nexart verify <snapshot>`
1346
1346
  * already exists for Code Mode snapshots and must not change (additive only).
1347
1347
  */
1348
- export function verifyBundleStandaloneCommand(file, opts) {
1348
+ export async function verifyBundleStandaloneCommand(file, opts) {
1349
1349
  let bundle;
1350
1350
  try {
1351
1351
  bundle = JSON.parse(fs.readFileSync(file, 'utf-8'));
@@ -1369,34 +1369,304 @@ export function verifyBundleStandaloneCommand(file, opts) {
1369
1369
  kid: opts.kid,
1370
1370
  verbose: opts.verbose,
1371
1371
  });
1372
+ // ── OPTIONAL, ADDITIVE: RFC 3161 timestamp verification ──────────────────────
1373
+ // Strictly additive: this NEVER alters integrity, signature, status, or the exit
1374
+ // code, and the heavy ASN.1 stack is only imported when --verify-timestamp is set.
1375
+ // resolveTimestampResult stays synchronous unless it actually verifies a token, so
1376
+ // a timestamp-less bundle never introduces an await (preserving prior sync behavior).
1377
+ const maybeTsa = resolveTimestampResult(evaluateBundleTimestamp(bundle), opts);
1378
+ const tsaResult = maybeTsa instanceof Promise ? await maybeTsa : maybeTsa;
1379
+ renderStandaloneVerification(result, tsaResult, opts);
1380
+ // Exit code is determined SOLELY by integrity + signature — never by the timestamp.
1381
+ process.exitCode = result.status === 'verified' ? 0 : 1;
1382
+ return result;
1383
+ }
1384
+ /**
1385
+ * Resolve the (strictly additive) RFC 3161 timestamp status for an already-located
1386
+ * timestamp. Shared by `verify-bundle` and `verify-record`. Every operational failure
1387
+ * is mapped to a timestamp STATUS — never to process.exit or a thrown error — so the
1388
+ * timestamp layer can never influence the exit code. The ASN.1 stack (pkijs/asn1js)
1389
+ * is dynamically imported ONLY when --verify-timestamp is set and a token is present.
1390
+ *
1391
+ * Returns SYNCHRONOUSLY for every non-verify path (not-present / present-unverified /
1392
+ * unreadable-roots) and only returns a Promise when it actually has async work to do
1393
+ * (the dynamic import + verification). This mirrors the original verify-bundle control
1394
+ * flow exactly — where the sole `await` lived behind `present && --verify-timestamp` —
1395
+ * so timestamp-less bundles stay fully synchronous (callers must handle both shapes).
1396
+ */
1397
+ function resolveTimestampResult(timestampInfo, opts) {
1398
+ if (!timestampInfo.present)
1399
+ return { status: 'not-present' };
1400
+ if (!opts.verifyTimestamp)
1401
+ return { status: 'present-unverified' };
1402
+ let trustedRootsPem;
1403
+ if (opts.tsaRoots) {
1404
+ try {
1405
+ trustedRootsPem = fs.readFileSync(opts.tsaRoots, 'utf-8');
1406
+ }
1407
+ catch (err) {
1408
+ return {
1409
+ status: 'untrusted-root',
1410
+ reason: `could not read TSA roots "${opts.tsaRoots}": ${err instanceof Error ? err.message : String(err)}`,
1411
+ };
1412
+ }
1413
+ }
1414
+ return (async () => {
1415
+ try {
1416
+ const { verifyRfc3161Timestamp } = await import('./tsaVerify.js');
1417
+ return await verifyRfc3161Timestamp({
1418
+ token: timestampInfo.token,
1419
+ certificateHash: timestampInfo.certificateHash ?? '',
1420
+ createdAt: timestampInfo.createdAt,
1421
+ trustedRootsPem,
1422
+ });
1423
+ }
1424
+ catch (err) {
1425
+ return { status: 'malformed', reason: err instanceof Error ? err.message : String(err) };
1426
+ }
1427
+ })();
1428
+ }
1429
+ /**
1430
+ * Render a standalone verification result plus its (additive) timestamp status, in
1431
+ * either the human-readable or --json form. Shared by `verify-bundle` and
1432
+ * `verify-record` so the two commands are byte-for-byte identical in output. This
1433
+ * never sets the exit code — that stays a function of integrity + signature alone.
1434
+ */
1435
+ function renderStandaloneVerification(result, tsaResult, opts) {
1372
1436
  if (opts.json) {
1373
1437
  process.stdout.write(JSON.stringify({
1374
1438
  status: result.status,
1375
1439
  integrity: result.integrity,
1376
1440
  signature: result.signature,
1377
1441
  protocolVersion: result.protocolVersion,
1442
+ timestamp: {
1443
+ status: tsaResult.status,
1444
+ ...(tsaResult.tsa ? { tsa: tsaResult.tsa } : {}),
1445
+ ...(tsaResult.genTime ? { genTime: tsaResult.genTime } : {}),
1446
+ ...(tsaResult.reason ? { reason: tsaResult.reason } : {}),
1447
+ },
1378
1448
  }, null, 2) + '\n');
1449
+ return;
1450
+ }
1451
+ const mark = (p) => p === 'pass' ? '\u2714' : p === 'fail' ? '\u2716' : '\u25CB';
1452
+ const label = (p) => p.toUpperCase();
1453
+ if (opts.verbose && result.steps.length > 0) {
1454
+ console.log('Verification steps:');
1455
+ for (const s of result.steps)
1456
+ console.log(` - ${s}`);
1457
+ console.log('');
1379
1458
  }
1380
- else {
1381
- const mark = (p) => p === 'pass' ? '\u2714' : p === 'fail' ? '\u2716' : '\u25CB';
1382
- const label = (p) => p.toUpperCase();
1383
- if (opts.verbose && result.steps.length > 0) {
1384
- console.log('Verification steps:');
1385
- for (const s of result.steps)
1386
- console.log(` - ${s}`);
1387
- console.log('');
1459
+ console.log(`${mark(result.integrity)} Integrity: ${label(result.integrity)}`);
1460
+ console.log(`${mark(result.signature)} Signature: ${label(result.signature)}`);
1461
+ console.log(`Protocol: ${result.protocolVersion}`);
1462
+ console.log(formatTimestampLine(tsaResult.status));
1463
+ const tsDetails = formatTimestampDetails(tsaResult);
1464
+ if (tsDetails.length > 0) {
1465
+ console.log('');
1466
+ console.log('Timestamp Details:');
1467
+ for (const d of tsDetails)
1468
+ console.log(` ${d}`);
1469
+ }
1470
+ if (result.status === 'failed' || result.reasons.length > 0) {
1471
+ for (const r of result.reasons)
1472
+ console.error(` ! ${r}`);
1473
+ }
1474
+ if (opts.verbose && tsaResult.reason && tsaResult.status !== 'valid') {
1475
+ console.error(` ! Timestamp: ${tsaResult.reason}`);
1476
+ }
1477
+ }
1478
+ /**
1479
+ * nexart verify-record — Verify a full NexArt CER RECORD offline and independently.
1480
+ *
1481
+ * A "record" is the full verification envelope: { bundle, verification: { receipt,
1482
+ * receiptSignature }, timestamp? }. This command is STRICTLY ADDITIVE alongside
1483
+ * `verify-bundle` and reuses the exact same engine:
1484
+ * - INTEGRITY + SIGNATURE: the record's `verification.receipt` /
1485
+ * `verification.receiptSignature` are merged into a SHALLOW copy of `bundle`
1486
+ * (which never affects the recomputed certificateHash — receipt/signature are
1487
+ * not part of the certificate payload) and handed to verifyBundleStandalone().
1488
+ * Behaviour, including --public-key / --kid, is identical to verify-bundle.
1489
+ * - TIMESTAMP (optional, additive): the record-level `timestamp.token` (or a
1490
+ * bundle-embedded timestamp) is verified via the same resolveTimestampResult()
1491
+ * path used by verify-bundle. It NEVER affects integrity, signature, or the
1492
+ * exit code, and the ASN.1 stack loads only behind --verify-timestamp.
1493
+ */
1494
+ export async function verifyRecordStandaloneCommand(file, opts) {
1495
+ let record;
1496
+ try {
1497
+ record = JSON.parse(fs.readFileSync(file, 'utf-8'));
1498
+ }
1499
+ catch (err) {
1500
+ console.error(`[nexart] Error: could not read record "${file}": ${err instanceof Error ? err.message : String(err)}`);
1501
+ process.exit(1);
1502
+ }
1503
+ let publicKeyMaterial;
1504
+ if (opts.publicKey) {
1505
+ try {
1506
+ publicKeyMaterial = fs.readFileSync(opts.publicKey, 'utf-8');
1388
1507
  }
1389
- console.log(`${mark(result.integrity)} Integrity: ${label(result.integrity)}`);
1390
- console.log(`${mark(result.signature)} Signature: ${label(result.signature)}`);
1391
- console.log(`Protocol: ${result.protocolVersion}`);
1392
- if (result.status === 'failed' || result.reasons.length > 0) {
1393
- for (const r of result.reasons)
1394
- console.error(` ! ${r}`);
1508
+ catch (err) {
1509
+ console.error(`[nexart] Error: could not read public key "${opts.publicKey}": ${err instanceof Error ? err.message : String(err)}`);
1510
+ process.exit(1);
1395
1511
  }
1396
1512
  }
1513
+ const rec = (record && typeof record === 'object') ? record : {};
1514
+ const bundleObj = rec.bundle && typeof rec.bundle === 'object' ? rec.bundle : undefined;
1515
+ if (!bundleObj) {
1516
+ // Malformed record: surface a clear reason but DO NOT crash the process — fall
1517
+ // through to the standalone verifier, which reports a clean integrity FAIL.
1518
+ console.error('[nexart] Error: record is missing a "bundle" object');
1519
+ }
1520
+ // Merge the record's signed receipt into a SHALLOW copy of the bundle so the
1521
+ // existing verifier finds it. This never mutates the original and never changes the
1522
+ // recomputed certificateHash (receipt/signature are not part of the cert payload).
1523
+ const verification = rec.verification && typeof rec.verification === 'object' ? rec.verification : undefined;
1524
+ const receipt = verification?.receipt;
1525
+ const receiptSignature = verification?.receiptSignature;
1526
+ const verifyInput = { ...bundleObj };
1527
+ if (receipt && typeof receipt === 'object' && typeof receiptSignature === 'string') {
1528
+ verifyInput.receipt = receipt;
1529
+ verifyInput.signature = receiptSignature;
1530
+ }
1531
+ const result = verifyBundleStandalone(bundleObj ? verifyInput : undefined, {
1532
+ publicKeyMaterial,
1533
+ kid: opts.kid,
1534
+ verbose: opts.verbose,
1535
+ });
1536
+ // Locate the timestamp for the additive layer. The record-level `timestamp`
1537
+ // (singular) takes precedence; otherwise fall back to a bundle-embedded one. The
1538
+ // certificateHash / createdAt the verifier needs always come from the bundle.
1539
+ const tsLocatorInput = rec.timestamp !== undefined
1540
+ ? { certificateHash: bundleObj?.certificateHash, createdAt: bundleObj?.createdAt, timestamp: rec.timestamp }
1541
+ : {
1542
+ certificateHash: bundleObj?.certificateHash,
1543
+ createdAt: bundleObj?.createdAt,
1544
+ timestamp: bundleObj?.timestamp,
1545
+ trustedTimestamps: bundleObj?.trustedTimestamps,
1546
+ };
1547
+ const maybeTsa = resolveTimestampResult(evaluateBundleTimestamp(tsLocatorInput), opts);
1548
+ const tsaResult = maybeTsa instanceof Promise ? await maybeTsa : maybeTsa;
1549
+ renderStandaloneVerification(result, tsaResult, opts);
1550
+ // Exit code is determined SOLELY by integrity + signature — never by the timestamp.
1397
1551
  process.exitCode = result.status === 'verified' ? 0 : 1;
1398
1552
  return result;
1399
1553
  }
1554
+ /**
1555
+ * Locate an RFC 3161 timestamp in a CER bundle WITHOUT mutating it. Supports both
1556
+ * the canonical `trustedTimestamps[]` array (each { type:'rfc3161', tsa, timestamp,
1557
+ * token }) and a singular `timestamp` object. Returns the base64 token plus the
1558
+ * certificateHash/createdAt needed to verify it. Reading this never affects any
1559
+ * other verification result.
1560
+ */
1561
+ function evaluateBundleTimestamp(bundle) {
1562
+ if (!bundle || typeof bundle !== 'object')
1563
+ return { present: false };
1564
+ const b = bundle;
1565
+ const certificateHash = typeof b.certificateHash === 'string' ? b.certificateHash : undefined;
1566
+ const createdAt = typeof b.createdAt === 'string' ? b.createdAt : undefined;
1567
+ let token;
1568
+ const arr = b.trustedTimestamps;
1569
+ if (Array.isArray(arr)) {
1570
+ const entry = arr.find((e) => e && typeof e === 'object' && e.type === 'rfc3161' && typeof e.token === 'string');
1571
+ if (entry)
1572
+ token = entry.token;
1573
+ }
1574
+ if (!token && b.timestamp && typeof b.timestamp === 'object') {
1575
+ const t = b.timestamp;
1576
+ if (typeof t.token === 'string')
1577
+ token = t.token;
1578
+ }
1579
+ return token ? { present: true, token, certificateHash, createdAt } : { present: false };
1580
+ }
1581
+ /**
1582
+ * Render the human-readable timestamp STATUS line WITHOUT importing the ASN.1 stack.
1583
+ * This deliberately mirrors formatTsaLine() in tsaVerify.ts but lives here so the
1584
+ * present-unverified / not-present paths never load pkijs (non-negotiable: ASN.1
1585
+ * libs are only loaded behind --verify-timestamp). The wording — "Externally
1586
+ * anchored timestamp" — reflects that we are proving anchoring to an INDEPENDENT
1587
+ * authority, not running an internal check. Provider/time/imprint are surfaced
1588
+ * separately in the Timestamp Details block (formatTimestampDetails).
1589
+ */
1590
+ export function formatTimestampLine(status) {
1591
+ const CHECK = '\u2714';
1592
+ const CROSS = '\u2716';
1593
+ const CIRCLE = '\u25CB';
1594
+ switch (status) {
1595
+ case 'valid':
1596
+ return `${CHECK} Externally anchored timestamp: valid`;
1597
+ case 'invalid-signature':
1598
+ return `${CROSS} Externally anchored timestamp: invalid (signature)`;
1599
+ case 'invalid-imprint':
1600
+ return `${CROSS} Externally anchored timestamp: invalid (imprint mismatch)`;
1601
+ case 'untrusted-root':
1602
+ return `${CROSS} Externally anchored timestamp: untrusted (unknown root)`;
1603
+ case 'expired':
1604
+ return `${CROSS} Externally anchored timestamp: invalid (expired)`;
1605
+ case 'invalid-time':
1606
+ return `${CROSS} Externally anchored timestamp: invalid (time)`;
1607
+ case 'malformed':
1608
+ return `${CROSS} Externally anchored timestamp: invalid (malformed token)`;
1609
+ case 'present-unverified':
1610
+ return `${CIRCLE} Externally anchored timestamp: present (not verified)`;
1611
+ case 'not-present':
1612
+ default:
1613
+ return `${CIRCLE} Externally anchored timestamp: not present`;
1614
+ }
1615
+ }
1616
+ /**
1617
+ * Build the "Timestamp Details" block lines (Provider / Time / Imprint). Returns an
1618
+ * empty array when no verification was attempted (flag absent → present-unverified,
1619
+ * or no token → not-present), so the block only appears for an actually-verified
1620
+ * token. Pure string logic — no ASN.1 import. The imprint state is INFERRED from the
1621
+ * status: the verifier checks signature → imprint → trust → validity → time in that
1622
+ * order, so any status at or past the trust stage implies the imprint already matched.
1623
+ */
1624
+ function formatTimestampDetails(result) {
1625
+ if (result.status === 'present-unverified' || result.status === 'not-present')
1626
+ return [];
1627
+ // No genTime ⇒ the token was never parsed (operational error such as an unreadable
1628
+ // --tsa-roots, or a malformed token). There is nothing meaningful to show, and in
1629
+ // particular the imprint was NOT checked — so suppress the block entirely.
1630
+ if (!result.genTime)
1631
+ return [];
1632
+ const time = result.genTime.replace(/\.\d{3}Z$/, 'Z');
1633
+ return [
1634
+ `Provider: ${deriveTsaProvider(result.tsa)}`,
1635
+ `Time: ${time}`,
1636
+ `Imprint: ${imprintState(result.status)}`,
1637
+ ];
1638
+ }
1639
+ /** Map a TSA signer common name to a friendly provider label (best-effort). */
1640
+ function deriveTsaProvider(tsa) {
1641
+ if (!tsa)
1642
+ return 'unknown';
1643
+ const n = tsa.toLowerCase();
1644
+ if (n.includes('digicert'))
1645
+ return 'DigiCert';
1646
+ if (n.includes('freetsa') || n.includes('free tsa'))
1647
+ return 'freeTSA';
1648
+ if (n.includes('sectigo'))
1649
+ return 'Sectigo';
1650
+ if (n.includes('globalsign'))
1651
+ return 'GlobalSign';
1652
+ if (n.includes('apple'))
1653
+ return 'Apple';
1654
+ return tsa;
1655
+ }
1656
+ /** Infer imprint state from the verification status (see formatTimestampDetails). */
1657
+ function imprintState(status) {
1658
+ switch (status) {
1659
+ case 'valid':
1660
+ case 'untrusted-root':
1661
+ case 'expired':
1662
+ case 'invalid-time':
1663
+ return 'match';
1664
+ case 'invalid-imprint':
1665
+ return 'mismatch \u2716';
1666
+ default:
1667
+ return 'not checked';
1668
+ }
1669
+ }
1400
1670
  /**
1401
1671
  * nexart ai project-verify — Read a cer.project.bundle.v1 JSON from file or stdin,
1402
1672
  * delegate all verification to verifyProjectBundle() from @nexart/ai-execution,
@@ -1876,16 +2146,75 @@ if (_isMainModule) {
1876
2146
  describe: 'Show intermediate verification steps',
1877
2147
  type: 'boolean',
1878
2148
  default: false,
2149
+ })
2150
+ .option('verify-timestamp', {
2151
+ describe: 'Additionally verify an embedded RFC 3161 timestamp (offline; never affects the exit code)',
2152
+ type: 'boolean',
2153
+ default: false,
2154
+ })
2155
+ .option('tsa-roots', {
2156
+ describe: 'Path to trusted TSA root certificate(s) (PEM) for --verify-timestamp',
2157
+ type: 'string',
1879
2158
  })
1880
2159
  .example('$0 verify-bundle cer.json', 'Verify integrity offline')
1881
2160
  .example('$0 verify-bundle cer.json --public-key node-keys.json', 'Verify integrity and the node signature')
1882
- .example('$0 verify-bundle cer.json --json', 'Emit a machine-readable result');
2161
+ .example('$0 verify-bundle cer.json --json', 'Emit a machine-readable result')
2162
+ .example('$0 verify-bundle cer.json --verify-timestamp --tsa-roots tsa-roots.pem', 'Also verify an RFC 3161 timestamp');
2163
+ }, async (argv) => {
2164
+ await verifyBundleStandaloneCommand(argv.file, {
2165
+ publicKey: argv['public-key'],
2166
+ kid: argv.kid,
2167
+ json: argv.json,
2168
+ verbose: argv.verbose,
2169
+ verifyTimestamp: argv['verify-timestamp'],
2170
+ tsaRoots: argv['tsa-roots'],
2171
+ });
2172
+ })
2173
+ .command('verify-record <file>', 'Verify a full CER record (bundle + signature + externally anchored timestamp) offline', (yargs) => {
2174
+ return yargs
2175
+ .positional('file', {
2176
+ describe: 'Path to the CER record JSON file ({ bundle, verification, timestamp? })',
2177
+ type: 'string',
2178
+ demandOption: true,
2179
+ })
2180
+ .option('public-key', {
2181
+ describe: 'Path to the Ed25519 public key (PEM, JWK, NodeKeysDocument, or raw base64url)',
2182
+ type: 'string',
2183
+ })
2184
+ .option('kid', {
2185
+ describe: 'Key id to select when --public-key is a NodeKeysDocument',
2186
+ type: 'string',
2187
+ })
2188
+ .option('json', {
2189
+ describe: 'Output machine-readable JSON',
2190
+ type: 'boolean',
2191
+ default: false,
2192
+ })
2193
+ .option('verbose', {
2194
+ describe: 'Show intermediate verification steps',
2195
+ type: 'boolean',
2196
+ default: false,
2197
+ })
2198
+ .option('verify-timestamp', {
2199
+ describe: 'Additionally verify the externally anchored RFC 3161 timestamp (offline; never affects the exit code)',
2200
+ type: 'boolean',
2201
+ default: false,
2202
+ })
2203
+ .option('tsa-roots', {
2204
+ describe: 'Path to trusted TSA root certificate(s) (PEM) for --verify-timestamp',
2205
+ type: 'string',
2206
+ })
2207
+ .example('$0 verify-record record.json', 'Verify the bundle integrity offline')
2208
+ .example('$0 verify-record record.json --public-key node-keys.json', 'Verify integrity and the receipt signature')
2209
+ .example('$0 verify-record record.json --public-key node-keys.json --verify-timestamp --tsa-roots tsa-roots.pem', 'Full CER + externally anchored timestamp verification');
1883
2210
  }, async (argv) => {
1884
- verifyBundleStandaloneCommand(argv.file, {
2211
+ await verifyRecordStandaloneCommand(argv.file, {
1885
2212
  publicKey: argv['public-key'],
1886
2213
  kid: argv.kid,
1887
2214
  json: argv.json,
1888
2215
  verbose: argv.verbose,
2216
+ verifyTimestamp: argv['verify-timestamp'],
2217
+ tsaRoots: argv['tsa-roots'],
1889
2218
  });
1890
2219
  })
1891
2220
  .command('export-verification [file]', 'Produce a self-contained verification package so a third party can verify a CER bundle independently', (yargs) => {