@nexart/cli 0.15.0 → 0.16.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
@@ -66,7 +66,8 @@ import { realpathSync } from 'fs';
66
66
  import { createRequire } from 'module';
67
67
  // Standalone (SDK-free) CER verifier. This module imports ONLY node:crypto and
68
68
  // nothing from @nexart/* — it is the auditable, dependency-free verification core.
69
- import { verifyBundleStandalone, } from './standaloneVerify.js';
69
+ import { verifyBundleStandalone, resolvePublicKey, } from './standaloneVerify.js';
70
+ import { DIGICERT_DEFAULT_ROOTS_PEM } from './trustRoots.js';
70
71
  import { verifyProjectBundle, verifyAiCerBundleDetailed, hasAttestation as sdkHasAttestation, isCerPackage as sdkIsCerPackage, getCerFromPackage, createSnapshot as sdkCreateSnapshot, sealCer as sdkSealCer, attachAnchor as sdkAttachAnchor, verifyAnchors as sdkVerifyAnchors, verifyTrustedTimestamps as sdkVerifyTrustedTimestamps, exportVerificationPackage as sdkExportVerificationPackage, isSupportedProtocolVersion, } from '@nexart/ai-execution';
71
72
  // Deterministic local renderer — reuses the canonical CodeMode SDK render core.
72
73
  // Importing the module does NOT load the native `canvas` package; `canvas` is
@@ -1397,9 +1398,15 @@ export async function verifyBundleStandaloneCommand(file, opts) {
1397
1398
  function resolveTimestampResult(timestampInfo, opts) {
1398
1399
  if (!timestampInfo.present)
1399
1400
  return { status: 'not-present' };
1400
- if (!opts.verifyTimestamp)
1401
+ // `auto` (verify-record) always verifies when a token is present; `verifyTimestamp`
1402
+ // (verify-bundle) keeps its opt-in gate so its behaviour is byte-for-byte unchanged.
1403
+ if (!opts.auto && !opts.verifyTimestamp)
1401
1404
  return { status: 'present-unverified' };
1402
- let trustedRootsPem;
1405
+ // Trust roots: an explicit --tsa-roots always wins (used by tests to anchor a
1406
+ // freeTSA fixture); otherwise fall back to the caller's embedded default store
1407
+ // (DigiCert, for verify-record). verify-bundle passes no default, so a missing
1408
+ // --tsa-roots there still yields 'untrusted-root' exactly as before.
1409
+ let trustedRootsPem = opts.defaultRootsPem;
1403
1410
  if (opts.tsaRoots) {
1404
1411
  try {
1405
1412
  trustedRootsPem = fs.readFileSync(opts.tsaRoots, 'utf-8');
@@ -1475,82 +1482,423 @@ function renderStandaloneVerification(result, tsaResult, opts) {
1475
1482
  console.error(` ! Timestamp: ${tsaResult.reason}`);
1476
1483
  }
1477
1484
  }
1485
+ export async function verifyRecordStandaloneCommand(file, opts) {
1486
+ // A single "-" reads the record from stdin, enabling pipelines such as
1487
+ // `curl … | nexart verify-record -`.
1488
+ const record = readJsonInput(file, 'record');
1489
+ return verifyRecordFromObject(record, opts);
1490
+ }
1478
1491
  /**
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.
1492
+ * Verify an already-parsed CER record (or bare bundle) object and render the
1493
+ * audit-grade report. This is the shared core behind `verify-record` (file / stdin)
1494
+ * and `verify-url` (network), so key resolution, timestamp verification, and the rich
1495
+ * renderer behave identically across all input sources.
1493
1496
  */
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);
1497
+ export async function verifyRecordFromObject(record, opts) {
1498
+ const rec = (record && typeof record === 'object') ? record : {};
1499
+ // FLEXIBLE PARSING: accept either { bundle, verification?, timestamp? } or a bare
1500
+ // bundle as the record root. No "missing bundle" error — a structurally invalid
1501
+ // record falls through to a clean Integrity FAIL from the verifier.
1502
+ const bundleObj = rec.bundle && typeof rec.bundle === 'object'
1503
+ ? rec.bundle
1504
+ : rec;
1505
+ // Merge the record's signed receipt into a SHALLOW copy of the bundle so the
1506
+ // existing verifier finds it. This never mutates the original and never changes the
1507
+ // recomputed certificateHash (receipt/signature are not part of the cert payload).
1508
+ const verification = rec.verification && typeof rec.verification === 'object' ? rec.verification : undefined;
1509
+ const recReceipt = verification?.receipt;
1510
+ const receiptSignature = verification?.receiptSignature;
1511
+ const verifyInput = { ...bundleObj };
1512
+ if (recReceipt && typeof recReceipt === 'object' && typeof receiptSignature === 'string') {
1513
+ verifyInput.receipt = recReceipt;
1514
+ verifyInput.signature = receiptSignature;
1502
1515
  }
1516
+ // Receipt used for display + key selection: prefer the record's, else one already
1517
+ // embedded in the bundle (root-as-bundle / certified bundles).
1518
+ const receiptObj = (recReceipt && typeof recReceipt === 'object' ? recReceipt : undefined) ??
1519
+ (bundleObj.receipt && typeof bundleObj.receipt === 'object' ? bundleObj.receipt : undefined);
1520
+ const attestorKeyId = opts.kid ??
1521
+ (typeof receiptObj?.attestorKeyId === 'string' ? receiptObj.attestorKeyId : undefined);
1522
+ // ── Resolve the signing public key (priority: override → well-known → embedded) ──
1503
1523
  let publicKeyMaterial;
1524
+ let keySource = 'none';
1525
+ let nodeMeta = {};
1504
1526
  if (opts.publicKey) {
1505
1527
  try {
1506
1528
  publicKeyMaterial = fs.readFileSync(opts.publicKey, 'utf-8');
1529
+ keySource = 'override';
1507
1530
  }
1508
1531
  catch (err) {
1509
1532
  console.error(`[nexart] Error: could not read public key "${opts.publicKey}": ${err instanceof Error ? err.message : String(err)}`);
1510
1533
  process.exit(1);
1511
1534
  }
1512
1535
  }
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;
1536
+ else if (!opts.offline) {
1537
+ const url = opts.wellKnownUrl ?? process.env.NEXART_NODE_WELLKNOWN_URL ?? DEFAULT_WELLKNOWN_URL;
1538
+ const doc = await fetchNodeKeysDocument(url);
1539
+ if (doc) {
1540
+ // Only commit to the well-known document if it can actually yield a usable key
1541
+ // for the target kid. resolvePublicKey is pure (node:crypto, no network); if it
1542
+ // throws (malformed doc, or no key matching attestorKeyId), fall through to any
1543
+ // bundle-embedded key, preserving the priority override > well-known > embedded.
1544
+ try {
1545
+ resolvePublicKey(doc.text, attestorKeyId);
1546
+ publicKeyMaterial = doc.text;
1547
+ keySource = 'well-known';
1548
+ nodeMeta = { nodeId: doc.nodeId, operator: doc.operator };
1549
+ }
1550
+ catch {
1551
+ keySource = 'well-known-unusable';
1552
+ }
1553
+ }
1554
+ else {
1555
+ keySource = 'well-known-unreachable';
1556
+ }
1530
1557
  }
1531
- const result = verifyBundleStandalone(bundleObj ? verifyInput : undefined, {
1558
+ const result = verifyBundleStandalone(verifyInput, {
1532
1559
  publicKeyMaterial,
1533
- kid: opts.kid,
1560
+ kid: attestorKeyId,
1534
1561
  verbose: opts.verbose,
1535
1562
  });
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);
1563
+ // If no override / well-known key was used but the signature still verified, the
1564
+ // verifier must have used key material embedded in the bundle.
1565
+ if ((keySource === 'none' || keySource === 'well-known-unreachable' || keySource === 'well-known-unusable') &&
1566
+ result.signature !== 'skipped') {
1567
+ keySource = 'embedded';
1568
+ }
1569
+ // Locate the timestamp. Precedence: record.verification.timestamp →
1570
+ // record.timestamp → bundle-embedded. certificateHash / createdAt always come from
1571
+ // the bundle (they are what the RFC 3161 imprint is verified against).
1572
+ const recVerTimestamp = verification && verification.timestamp !== undefined ? verification.timestamp : undefined;
1573
+ const tsLocatorInput = recVerTimestamp !== undefined
1574
+ ? { certificateHash: bundleObj.certificateHash, createdAt: bundleObj.createdAt, timestamp: recVerTimestamp }
1575
+ : rec.timestamp !== undefined
1576
+ ? { certificateHash: bundleObj.certificateHash, createdAt: bundleObj.createdAt, timestamp: rec.timestamp }
1577
+ : {
1578
+ certificateHash: bundleObj.certificateHash,
1579
+ createdAt: bundleObj.createdAt,
1580
+ timestamp: bundleObj.timestamp,
1581
+ trustedTimestamps: bundleObj.trustedTimestamps,
1582
+ };
1583
+ const maybeTsa = resolveTimestampResult(evaluateBundleTimestamp(tsLocatorInput), {
1584
+ auto: true,
1585
+ tsaRoots: opts.tsaRoots,
1586
+ defaultRootsPem: DIGICERT_DEFAULT_ROOTS_PEM,
1587
+ });
1548
1588
  const tsaResult = maybeTsa instanceof Promise ? await maybeTsa : maybeTsa;
1549
- renderStandaloneVerification(result, tsaResult, opts);
1589
+ renderRecordVerification(result, tsaResult, {
1590
+ bundle: bundleObj,
1591
+ receipt: receiptObj,
1592
+ keySource,
1593
+ nodeMeta,
1594
+ opts,
1595
+ });
1550
1596
  // Exit code is determined SOLELY by integrity + signature — never by the timestamp.
1551
1597
  process.exitCode = result.status === 'verified' ? 0 : 1;
1552
1598
  return result;
1553
1599
  }
1600
+ /**
1601
+ * Read JSON from a file path, or from stdin when `file` is "-". A read or parse error
1602
+ * prints a clean message and exits 1 (never throws an uncaught error). Used by both
1603
+ * `verify-record <file|->` and any other command that accepts piped JSON.
1604
+ */
1605
+ function readJsonInput(file, label) {
1606
+ let raw;
1607
+ try {
1608
+ raw = file === '-' ? fs.readFileSync(0, 'utf-8') : fs.readFileSync(file, 'utf-8');
1609
+ }
1610
+ catch (err) {
1611
+ const src = file === '-' ? 'stdin' : `"${file}"`;
1612
+ console.error(`[nexart] Error: could not read ${label} from ${src}: ${err instanceof Error ? err.message : String(err)}`);
1613
+ process.exit(1);
1614
+ }
1615
+ try {
1616
+ return JSON.parse(raw);
1617
+ }
1618
+ catch (err) {
1619
+ const src = file === '-' ? 'stdin' : `"${file}"`;
1620
+ console.error(`[nexart] Error: ${label} from ${src} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
1621
+ process.exit(1);
1622
+ }
1623
+ }
1624
+ /**
1625
+ * nexart verify-url — Fetch a CER record (or bare bundle) from a URL and verify it
1626
+ * automatically, using the SAME rich renderer and key/timestamp resolution as
1627
+ * `verify-record`. The single network fetch here is the record SOURCE; `--offline`
1628
+ * still disables the (separate) signing-key auto-fetch, exactly as for verify-record.
1629
+ */
1630
+ export async function verifyUrlCommand(url, opts) {
1631
+ let text;
1632
+ try {
1633
+ const res = await fetch(url, { headers: { accept: 'application/json' } });
1634
+ if (!res.ok) {
1635
+ console.error(`[nexart] Error: could not fetch "${url}": HTTP ${res.status} ${res.statusText}`.trimEnd());
1636
+ process.exit(1);
1637
+ }
1638
+ text = await res.text();
1639
+ }
1640
+ catch (err) {
1641
+ console.error(`[nexart] Error: could not fetch "${url}": ${err instanceof Error ? err.message : String(err)}`);
1642
+ process.exit(1);
1643
+ }
1644
+ let record;
1645
+ try {
1646
+ record = JSON.parse(text);
1647
+ }
1648
+ catch (err) {
1649
+ console.error(`[nexart] Error: response from "${url}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
1650
+ process.exit(1);
1651
+ }
1652
+ // Detect the payload shape for an informational line. Both a full record and a bare
1653
+ // bundle route through the same rich verifier (which natively accepts either).
1654
+ if (!opts.json && !opts.short) {
1655
+ console.log(`Fetched ${detectRecordShape(record)} from ${url}`);
1656
+ console.log('');
1657
+ }
1658
+ return verifyRecordFromObject(record, opts);
1659
+ }
1660
+ /** Classify a fetched payload as a full verification record or a bare bundle. */
1661
+ function detectRecordShape(record) {
1662
+ if (record && typeof record === 'object') {
1663
+ const r = record;
1664
+ const v = r.verification;
1665
+ if (v && typeof v === 'object' && v.receipt)
1666
+ return 'full record';
1667
+ }
1668
+ return 'raw bundle';
1669
+ }
1670
+ /** Base URL for the public CER lookup endpoint resolved by `verify-hash <certificate_hash>`. */
1671
+ const PUBLIC_CER_ENDPOINT = 'https://node.nexart.io/v1/cer/public';
1672
+ /**
1673
+ * nexart verify-hash — Resolve a certificate hash to its public CER record URL and verify it.
1674
+ * This is a thin convenience wrapper over `verify-url`: it constructs
1675
+ * `${PUBLIC_CER_ENDPOINT}?certificate_hash=<hash>` and delegates, so the output is
1676
+ * identical to `verify-url`. The hash must carry the canonical `sha256:` prefix.
1677
+ */
1678
+ export async function verifyHashCommand(certificateHash, opts) {
1679
+ if (typeof certificateHash !== 'string' || !certificateHash.startsWith('sha256:')) {
1680
+ console.error(`[nexart] Error: certificate hash must start with "sha256:" (got "${certificateHash}")`);
1681
+ process.exit(1);
1682
+ }
1683
+ const url = `${PUBLIC_CER_ENDPOINT}?certificate_hash=${encodeURIComponent(certificateHash)}`;
1684
+ return verifyUrlCommand(url, opts);
1685
+ }
1686
+ /** Default location of the node's published Ed25519 signing key set. */
1687
+ const DEFAULT_WELLKNOWN_URL = 'https://node.nexart.io/.well-known/nexart-node.json';
1688
+ /**
1689
+ * Fetch the node's published key set (a NodeKeysDocument) over HTTPS. This is the ONLY
1690
+ * network call in the verify path and lives entirely in the CLI layer — the auditable
1691
+ * core (standaloneVerify.ts) never touches the network. Failure is graceful: any
1692
+ * network/HTTP/timeout error returns `undefined`, and verification falls back to an
1693
+ * explicit --public-key, an embedded key, or a skipped signature. It never throws.
1694
+ */
1695
+ async function fetchNodeKeysDocument(url, timeoutMs = 8000) {
1696
+ try {
1697
+ const controller = new AbortController();
1698
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1699
+ let res;
1700
+ try {
1701
+ res = await fetch(url, { signal: controller.signal, headers: { accept: 'application/json' } });
1702
+ }
1703
+ finally {
1704
+ clearTimeout(timer);
1705
+ }
1706
+ if (!res.ok)
1707
+ return undefined;
1708
+ const text = await res.text();
1709
+ let nodeId;
1710
+ let operator;
1711
+ try {
1712
+ const j = JSON.parse(text);
1713
+ if (typeof j.nodeId === 'string')
1714
+ nodeId = j.nodeId;
1715
+ if (typeof j.operator === 'string')
1716
+ operator = j.operator;
1717
+ }
1718
+ catch {
1719
+ // Not JSON we can summarise; resolvePublicKey will surface any real problem.
1720
+ }
1721
+ return { text, nodeId, operator };
1722
+ }
1723
+ catch {
1724
+ return undefined;
1725
+ }
1726
+ }
1727
+ /** Human-readable label for the Node info "Key source" line. */
1728
+ function keySourceLabel(source) {
1729
+ switch (source) {
1730
+ case 'override':
1731
+ return 'supplied via --public-key';
1732
+ case 'well-known':
1733
+ return 'node.nexart.io (.well-known)';
1734
+ case 'well-known-unreachable':
1735
+ return 'unavailable (.well-known unreachable)';
1736
+ case 'well-known-unusable':
1737
+ return 'unavailable (.well-known has no matching key)';
1738
+ case 'embedded':
1739
+ return 'embedded in bundle';
1740
+ case 'none':
1741
+ default:
1742
+ return 'none';
1743
+ }
1744
+ }
1745
+ /**
1746
+ * Map an RFC 3161 verification status to the verify-record timestamp headline
1747
+ * (VERIFIED / INVALID (<reason>) / NOT INCLUDED). Distinct from formatTimestampLine()
1748
+ * (verify-bundle), whose "Externally anchored timestamp: …" wording is unchanged.
1749
+ */
1750
+ function formatRecordTimestampLine(status) {
1751
+ const CHECK = '\u2714';
1752
+ const CROSS = '\u2716';
1753
+ const CIRCLE = '\u25CB';
1754
+ switch (status) {
1755
+ case 'valid':
1756
+ return `${CHECK} Timestamp: VERIFIED`;
1757
+ case 'invalid-signature':
1758
+ return `${CROSS} Timestamp: INVALID (signature)`;
1759
+ case 'invalid-imprint':
1760
+ return `${CROSS} Timestamp: INVALID (imprint mismatch)`;
1761
+ case 'untrusted-root':
1762
+ return `${CROSS} Timestamp: INVALID (untrusted root)`;
1763
+ case 'expired':
1764
+ return `${CROSS} Timestamp: INVALID (expired)`;
1765
+ case 'invalid-time':
1766
+ return `${CROSS} Timestamp: INVALID (time)`;
1767
+ case 'malformed':
1768
+ return `${CROSS} Timestamp: INVALID (malformed token)`;
1769
+ case 'present-unverified':
1770
+ return `${CIRCLE} Timestamp: PRESENT BUT NOT VERIFIED`;
1771
+ case 'not-present':
1772
+ default:
1773
+ return `${CIRCLE} Timestamp: NOT INCLUDED IN RECORD`;
1774
+ }
1775
+ }
1776
+ /**
1777
+ * Render the redesigned, audit-grade verify-record report (default human-readable, or
1778
+ * --json). Sections: Integrity, Signature, Timestamp, Node, Identity, Execution. This
1779
+ * is verify-record ONLY — renderStandaloneVerification (verify-bundle) is untouched.
1780
+ */
1781
+ function renderRecordVerification(result, tsaResult, ctx) {
1782
+ const snapshot = ctx.bundle.snapshot && typeof ctx.bundle.snapshot === 'object'
1783
+ ? ctx.bundle.snapshot
1784
+ : undefined;
1785
+ const identity = snapshot?.identity && typeof snapshot.identity === 'object'
1786
+ ? snapshot.identity
1787
+ : undefined;
1788
+ const params = snapshot?.parameters && typeof snapshot.parameters === 'object'
1789
+ ? snapshot.parameters
1790
+ : undefined;
1791
+ const str = (v) => (typeof v === 'string' ? v : undefined);
1792
+ const nodeId = ctx.nodeMeta.nodeId ?? str(ctx.receipt?.nodeId);
1793
+ const keyId = str(ctx.receipt?.attestorKeyId);
1794
+ const genTimeIso = tsaResult.genTime ? tsaResult.genTime.replace(/\.\d{3}Z$/, 'Z') : undefined;
1795
+ if (ctx.opts.json) {
1796
+ process.stdout.write(JSON.stringify({
1797
+ status: result.status,
1798
+ integrity: result.integrity,
1799
+ signature: result.signature,
1800
+ protocolVersion: result.protocolVersion,
1801
+ certificateHash: result.expectedCertificateHash,
1802
+ timestamp: {
1803
+ status: tsaResult.status,
1804
+ ...(tsaResult.tsa ? { tsa: tsaResult.tsa } : {}),
1805
+ ...(genTimeIso ? { genTime: genTimeIso } : {}),
1806
+ ...(tsaResult.reason ? { reason: tsaResult.reason } : {}),
1807
+ },
1808
+ node: {
1809
+ ...(nodeId ? { nodeId } : {}),
1810
+ ...(keyId ? { keyId } : {}),
1811
+ keySource: ctx.keySource,
1812
+ ...(ctx.nodeMeta.operator ? { operator: ctx.nodeMeta.operator } : {}),
1813
+ },
1814
+ identity: identity ?? null,
1815
+ execution: {
1816
+ ...(str(snapshot?.provider) ? { provider: str(snapshot?.provider) } : {}),
1817
+ ...(str(snapshot?.model) ? { model: str(snapshot?.model) } : {}),
1818
+ ...(str(snapshot?.inputHash) ? { inputHash: str(snapshot?.inputHash) } : {}),
1819
+ ...(str(snapshot?.outputHash) ? { outputHash: str(snapshot?.outputHash) } : {}),
1820
+ ...(params ? { parameters: params } : {}),
1821
+ ...(str(ctx.bundle.createdAt) ? { createdAt: str(ctx.bundle.createdAt) } : {}),
1822
+ },
1823
+ reasons: result.reasons,
1824
+ }, null, 2) + '\n');
1825
+ return;
1826
+ }
1827
+ const mark = (p) => p === 'pass' ? '\u2714' : p === 'fail' ? '\u2716' : '\u25CB';
1828
+ const label = (p) => p.toUpperCase();
1829
+ // --short: a single machine-glanceable verdict line, nothing else.
1830
+ if (ctx.opts.short) {
1831
+ console.log(result.status === 'verified' ? '\u2714 VERIFIED' : '\u2716 FAILED');
1832
+ return;
1833
+ }
1834
+ if (ctx.opts.verbose && result.steps.length > 0) {
1835
+ console.log('Verification steps:');
1836
+ for (const s of result.steps)
1837
+ console.log(` - ${s}`);
1838
+ console.log('');
1839
+ }
1840
+ // ── Verdict block ──
1841
+ console.log(`${mark(result.integrity)} Integrity: ${label(result.integrity)}`);
1842
+ console.log(`${mark(result.signature)} Signature: ${label(result.signature)}`);
1843
+ console.log(formatRecordTimestampLine(tsaResult.status));
1844
+ if (tsaResult.status !== 'not-present' && genTimeIso) {
1845
+ console.log(` Time: ${genTimeIso}`);
1846
+ console.log(` Authority: ${deriveTsaProvider(tsaResult.tsa)}`);
1847
+ }
1848
+ // ── Node info ──
1849
+ console.log('');
1850
+ console.log('Node');
1851
+ console.log(` Node ID: ${nodeId ?? '(unknown)'}`);
1852
+ console.log(` Key ID: ${keyId ?? '(unknown)'}`);
1853
+ console.log(` Key source: ${keySourceLabel(ctx.keySource)}`);
1854
+ if (ctx.nodeMeta.operator)
1855
+ console.log(` Operator: ${ctx.nodeMeta.operator}`);
1856
+ // ── Identity summary ──
1857
+ console.log('');
1858
+ console.log('Identity');
1859
+ if (identity) {
1860
+ for (const [k, v] of Object.entries(identity)) {
1861
+ console.log(` ${k}: ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`);
1862
+ }
1863
+ }
1864
+ else {
1865
+ console.log(' (none asserted)');
1866
+ }
1867
+ // ── Execution summary ──
1868
+ console.log('');
1869
+ console.log('Execution');
1870
+ console.log(` Protocol: ${result.protocolVersion}`);
1871
+ if (str(snapshot?.provider))
1872
+ console.log(` Provider: ${str(snapshot?.provider)}`);
1873
+ if (str(snapshot?.model))
1874
+ console.log(` Model: ${str(snapshot?.model)}`);
1875
+ if (str(snapshot?.inputHash))
1876
+ console.log(` Input: ${str(snapshot?.inputHash)}`);
1877
+ if (str(snapshot?.outputHash))
1878
+ console.log(` Output: ${str(snapshot?.outputHash)}`);
1879
+ if (params) {
1880
+ const pretty = Object.entries(params)
1881
+ .map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
1882
+ .join(', ');
1883
+ if (pretty)
1884
+ console.log(` Parameters: ${pretty}`);
1885
+ }
1886
+ if (str(ctx.bundle.createdAt))
1887
+ console.log(` Created: ${str(ctx.bundle.createdAt)}`);
1888
+ // ── Final verdict line ──
1889
+ console.log('');
1890
+ console.log(result.status === 'verified'
1891
+ ? 'FINAL STATUS: VERIFIED EVIDENCE RECORD'
1892
+ : 'FINAL STATUS: FAILED');
1893
+ // ── Failure reasons (stderr) ──
1894
+ if (result.status === 'failed' || result.reasons.length > 0) {
1895
+ for (const r of result.reasons)
1896
+ console.error(` ! ${r}`);
1897
+ }
1898
+ if (ctx.opts.verbose && tsaResult.reason && tsaResult.status !== 'valid') {
1899
+ console.error(` ! Timestamp: ${tsaResult.reason}`);
1900
+ }
1901
+ }
1554
1902
  /**
1555
1903
  * Locate an RFC 3161 timestamp in a CER bundle WITHOUT mutating it. Supports both
1556
1904
  * the canonical `trustedTimestamps[]` array (each { type:'rfc3161', tsa, timestamp,
@@ -2170,50 +2518,166 @@ if (_isMainModule) {
2170
2518
  tsaRoots: argv['tsa-roots'],
2171
2519
  });
2172
2520
  })
2173
- .command('verify-record <file>', 'Verify a full CER record (bundle + signature + externally anchored timestamp) offline', (yargs) => {
2521
+ .command('verify-record <file>', 'Verify a full CER record automatically: integrity, receipt signature, and RFC 3161 timestamp', (yargs) => {
2174
2522
  return yargs
2175
2523
  .positional('file', {
2176
- describe: 'Path to the CER record JSON file ({ bundle, verification, timestamp? })',
2524
+ describe: 'Path to the CER record JSON, or "-" to read it from stdin ({ bundle, verification?, timestamp? } or a bare bundle)',
2177
2525
  type: 'string',
2178
2526
  demandOption: true,
2179
2527
  })
2180
2528
  .option('public-key', {
2181
- describe: 'Path to the Ed25519 public key (PEM, JWK, NodeKeysDocument, or raw base64url)',
2529
+ describe: 'Override the signing key (PEM, JWK, NodeKeysDocument, or raw base64url). Default: auto-fetched from the node .well-known key set',
2182
2530
  type: 'string',
2531
+ })
2532
+ .option('offline', {
2533
+ describe: 'Do not contact the network; use --public-key or a bundle-embedded key only',
2534
+ type: 'boolean',
2535
+ default: false,
2183
2536
  })
2184
2537
  .option('kid', {
2185
- describe: 'Key id to select when --public-key is a NodeKeysDocument',
2538
+ describe: 'Key id to select from the key set (default: the receipt attestorKeyId)',
2186
2539
  type: 'string',
2187
2540
  })
2188
2541
  .option('json', {
2189
2542
  describe: 'Output machine-readable JSON',
2190
2543
  type: 'boolean',
2191
2544
  default: false,
2545
+ })
2546
+ .option('short', {
2547
+ describe: 'Print only a single verdict line (✔ VERIFIED / ✖ FAILED)',
2548
+ type: 'boolean',
2549
+ default: false,
2192
2550
  })
2193
2551
  .option('verbose', {
2194
2552
  describe: 'Show intermediate verification steps',
2195
2553
  type: 'boolean',
2196
2554
  default: false,
2197
2555
  })
2198
- .option('verify-timestamp', {
2199
- describe: 'Additionally verify the externally anchored RFC 3161 timestamp (offline; never affects the exit code)',
2556
+ .option('tsa-roots', {
2557
+ describe: 'Override the default DigiCert TSA trust roots with custom root certificate(s) (PEM)',
2558
+ type: 'string',
2559
+ })
2560
+ .example('$0 verify-record record.json', 'Full automatic verification (auto-fetch key, verify timestamp)')
2561
+ .example('$0 verify-record record.json --json', 'Machine-readable verification report')
2562
+ .example('curl -s https://node/record.json | $0 verify-record -', 'Verify a record piped from stdin')
2563
+ .example('$0 verify-record record.json --offline --public-key node-keys.json', 'Fully offline verification with an explicit key');
2564
+ }, async (argv) => {
2565
+ // yargs-parser collapses a bare "-" positional to an empty string; restore the
2566
+ // stdin sentinel so `verify-record -` reads the record from stdin.
2567
+ const file = argv.file === '' ? '-' : argv.file;
2568
+ await verifyRecordStandaloneCommand(file, {
2569
+ publicKey: argv['public-key'],
2570
+ kid: argv.kid,
2571
+ json: argv.json,
2572
+ short: argv.short,
2573
+ verbose: argv.verbose,
2574
+ offline: argv.offline,
2575
+ tsaRoots: argv['tsa-roots'],
2576
+ });
2577
+ })
2578
+ .command('verify-hash <certificate_hash>', 'Resolve a certificate hash to its public CER record and verify it — one-command verification', (yargs) => {
2579
+ return yargs
2580
+ .positional('certificate_hash', {
2581
+ describe: 'CER certificate hash (must include the "sha256:" prefix)',
2582
+ type: 'string',
2583
+ demandOption: true,
2584
+ })
2585
+ .option('public-key', {
2586
+ describe: 'Override the signing key (PEM, JWK, NodeKeysDocument, or raw base64url). Default: auto-fetched from the node .well-known key set',
2587
+ type: 'string',
2588
+ })
2589
+ .option('offline', {
2590
+ describe: 'Do not auto-fetch the signing key (the record itself is still fetched); use --public-key or a bundle-embedded key only',
2591
+ type: 'boolean',
2592
+ default: false,
2593
+ })
2594
+ .option('kid', {
2595
+ describe: 'Key id to select from the key set (default: the receipt attestorKeyId)',
2596
+ type: 'string',
2597
+ })
2598
+ .option('json', {
2599
+ describe: 'Output machine-readable JSON',
2600
+ type: 'boolean',
2601
+ default: false,
2602
+ })
2603
+ .option('short', {
2604
+ describe: 'Print only a single verdict line (✔ VERIFIED / ✖ FAILED)',
2605
+ type: 'boolean',
2606
+ default: false,
2607
+ })
2608
+ .option('verbose', {
2609
+ describe: 'Show intermediate verification steps',
2200
2610
  type: 'boolean',
2201
2611
  default: false,
2202
2612
  })
2203
2613
  .option('tsa-roots', {
2204
- describe: 'Path to trusted TSA root certificate(s) (PEM) for --verify-timestamp',
2614
+ describe: 'Override the default DigiCert TSA trust roots with custom root certificate(s) (PEM)',
2205
2615
  type: 'string',
2206
2616
  })
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');
2617
+ .example('$0 verify-hash sha256:abc123…', 'Resolve and fully verify a record by certificate hash')
2618
+ .example('$0 verify-hash sha256:abc123… --short', 'One-line PASS/FAIL verdict')
2619
+ .example('$0 verify-hash sha256:abc123… --json', 'Machine-readable verification report');
2210
2620
  }, async (argv) => {
2211
- await verifyRecordStandaloneCommand(argv.file, {
2621
+ await verifyHashCommand(argv.certificate_hash, {
2212
2622
  publicKey: argv['public-key'],
2213
2623
  kid: argv.kid,
2214
2624
  json: argv.json,
2625
+ short: argv.short,
2215
2626
  verbose: argv.verbose,
2216
- verifyTimestamp: argv['verify-timestamp'],
2627
+ offline: argv.offline,
2628
+ tsaRoots: argv['tsa-roots'],
2629
+ });
2630
+ })
2631
+ .command('verify-url <url>', 'Fetch a CER record (or bundle) from a URL and verify it automatically — one-command verification', (yargs) => {
2632
+ return yargs
2633
+ .positional('url', {
2634
+ describe: 'URL returning a CER record or bundle as JSON',
2635
+ type: 'string',
2636
+ demandOption: true,
2637
+ })
2638
+ .option('public-key', {
2639
+ describe: 'Override the signing key (PEM, JWK, NodeKeysDocument, or raw base64url). Default: auto-fetched from the node .well-known key set',
2640
+ type: 'string',
2641
+ })
2642
+ .option('offline', {
2643
+ describe: 'Do not auto-fetch the signing key (the record URL itself is still fetched); use --public-key or a bundle-embedded key only',
2644
+ type: 'boolean',
2645
+ default: false,
2646
+ })
2647
+ .option('kid', {
2648
+ describe: 'Key id to select from the key set (default: the receipt attestorKeyId)',
2649
+ type: 'string',
2650
+ })
2651
+ .option('json', {
2652
+ describe: 'Output machine-readable JSON',
2653
+ type: 'boolean',
2654
+ default: false,
2655
+ })
2656
+ .option('short', {
2657
+ describe: 'Print only a single verdict line (✔ VERIFIED / ✖ FAILED)',
2658
+ type: 'boolean',
2659
+ default: false,
2660
+ })
2661
+ .option('verbose', {
2662
+ describe: 'Show intermediate verification steps',
2663
+ type: 'boolean',
2664
+ default: false,
2665
+ })
2666
+ .option('tsa-roots', {
2667
+ describe: 'Override the default DigiCert TSA trust roots with custom root certificate(s) (PEM)',
2668
+ type: 'string',
2669
+ })
2670
+ .example('$0 verify-url https://node.example/records/abc.json', 'Fetch and fully verify a record')
2671
+ .example('$0 verify-url https://node.example/records/abc.json --short', 'One-line PASS/FAIL verdict')
2672
+ .example('$0 verify-url https://node.example/records/abc.json --json', 'Machine-readable verification report');
2673
+ }, async (argv) => {
2674
+ await verifyUrlCommand(argv.url, {
2675
+ publicKey: argv['public-key'],
2676
+ kid: argv.kid,
2677
+ json: argv.json,
2678
+ short: argv.short,
2679
+ verbose: argv.verbose,
2680
+ offline: argv.offline,
2217
2681
  tsaRoots: argv['tsa-roots'],
2218
2682
  });
2219
2683
  })