@nexart/cli 0.15.1 → 0.16.1

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,446 @@ 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,
1562
+ // Public records may be privacy-redacted (input/output content stripped). Classify
1563
+ // those as `integrity: 'redacted'` rather than a tamper FAIL.
1564
+ classifyRedacted: true,
1565
+ });
1566
+ // If no override / well-known key was used but the signature still verified, the
1567
+ // verifier must have used key material embedded in the bundle.
1568
+ if ((keySource === 'none' || keySource === 'well-known-unreachable' || keySource === 'well-known-unusable') &&
1569
+ result.signature !== 'skipped') {
1570
+ keySource = 'embedded';
1571
+ }
1572
+ // Locate the timestamp. Precedence: record.verification.timestamp →
1573
+ // record.timestamp → bundle-embedded. certificateHash / createdAt always come from
1574
+ // the bundle (they are what the RFC 3161 imprint is verified against).
1575
+ const recVerTimestamp = verification && verification.timestamp !== undefined ? verification.timestamp : undefined;
1576
+ const tsLocatorInput = recVerTimestamp !== undefined
1577
+ ? { certificateHash: bundleObj.certificateHash, createdAt: bundleObj.createdAt, timestamp: recVerTimestamp }
1578
+ : rec.timestamp !== undefined
1579
+ ? { certificateHash: bundleObj.certificateHash, createdAt: bundleObj.createdAt, timestamp: rec.timestamp }
1580
+ : {
1581
+ certificateHash: bundleObj.certificateHash,
1582
+ createdAt: bundleObj.createdAt,
1583
+ timestamp: bundleObj.timestamp,
1584
+ trustedTimestamps: bundleObj.trustedTimestamps,
1585
+ };
1586
+ const maybeTsa = resolveTimestampResult(evaluateBundleTimestamp(tsLocatorInput), {
1587
+ auto: true,
1588
+ tsaRoots: opts.tsaRoots,
1589
+ defaultRootsPem: DIGICERT_DEFAULT_ROOTS_PEM,
1535
1590
  });
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
1591
  const tsaResult = maybeTsa instanceof Promise ? await maybeTsa : maybeTsa;
1549
- renderStandaloneVerification(result, tsaResult, opts);
1592
+ renderRecordVerification(result, tsaResult, {
1593
+ bundle: bundleObj,
1594
+ receipt: receiptObj,
1595
+ keySource,
1596
+ nodeMeta,
1597
+ opts,
1598
+ });
1550
1599
  // Exit code is determined SOLELY by integrity + signature — never by the timestamp.
1551
- process.exitCode = result.status === 'verified' ? 0 : 1;
1600
+ // VERIFIED and AUTHENTIC (REDACTED) both exit 0; only FAILED exits 1.
1601
+ process.exitCode = result.status === 'failed' ? 1 : 0;
1552
1602
  return result;
1553
1603
  }
1604
+ /**
1605
+ * Read JSON from a file path, or from stdin when `file` is "-". A read or parse error
1606
+ * prints a clean message and exits 1 (never throws an uncaught error). Used by both
1607
+ * `verify-record <file|->` and any other command that accepts piped JSON.
1608
+ */
1609
+ function readJsonInput(file, label) {
1610
+ let raw;
1611
+ try {
1612
+ raw = file === '-' ? fs.readFileSync(0, 'utf-8') : fs.readFileSync(file, 'utf-8');
1613
+ }
1614
+ catch (err) {
1615
+ const src = file === '-' ? 'stdin' : `"${file}"`;
1616
+ console.error(`[nexart] Error: could not read ${label} from ${src}: ${err instanceof Error ? err.message : String(err)}`);
1617
+ process.exit(1);
1618
+ }
1619
+ try {
1620
+ return JSON.parse(raw);
1621
+ }
1622
+ catch (err) {
1623
+ const src = file === '-' ? 'stdin' : `"${file}"`;
1624
+ console.error(`[nexart] Error: ${label} from ${src} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
1625
+ process.exit(1);
1626
+ }
1627
+ }
1628
+ /**
1629
+ * nexart verify-url — Fetch a CER record (or bare bundle) from a URL and verify it
1630
+ * automatically, using the SAME rich renderer and key/timestamp resolution as
1631
+ * `verify-record`. The single network fetch here is the record SOURCE; `--offline`
1632
+ * still disables the (separate) signing-key auto-fetch, exactly as for verify-record.
1633
+ */
1634
+ export async function verifyUrlCommand(url, opts) {
1635
+ let text;
1636
+ try {
1637
+ const res = await fetch(url, { headers: { accept: 'application/json' } });
1638
+ if (!res.ok) {
1639
+ console.error(`[nexart] Error: could not fetch "${url}": HTTP ${res.status} ${res.statusText}`.trimEnd());
1640
+ process.exit(1);
1641
+ }
1642
+ text = await res.text();
1643
+ }
1644
+ catch (err) {
1645
+ console.error(`[nexart] Error: could not fetch "${url}": ${err instanceof Error ? err.message : String(err)}`);
1646
+ process.exit(1);
1647
+ }
1648
+ let record;
1649
+ try {
1650
+ record = JSON.parse(text);
1651
+ }
1652
+ catch (err) {
1653
+ console.error(`[nexart] Error: response from "${url}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
1654
+ process.exit(1);
1655
+ }
1656
+ // Detect the payload shape for an informational line. Both a full record and a bare
1657
+ // bundle route through the same rich verifier (which natively accepts either).
1658
+ if (!opts.json && !opts.short) {
1659
+ console.log(`Fetched ${detectRecordShape(record)} from ${url}`);
1660
+ console.log('');
1661
+ }
1662
+ return verifyRecordFromObject(record, opts);
1663
+ }
1664
+ /** Classify a fetched payload as a full verification record or a bare bundle. */
1665
+ function detectRecordShape(record) {
1666
+ if (record && typeof record === 'object') {
1667
+ const r = record;
1668
+ const v = r.verification;
1669
+ if (v && typeof v === 'object' && v.receipt)
1670
+ return 'full record';
1671
+ }
1672
+ return 'raw bundle';
1673
+ }
1674
+ /** Base URL for the public CER lookup endpoint resolved by `verify-hash <certificate_hash>`. */
1675
+ const PUBLIC_CER_ENDPOINT = 'https://node.nexart.io/v1/cer/public';
1676
+ /**
1677
+ * nexart verify-hash — Resolve a certificate hash to its public CER record URL and verify it.
1678
+ * This is a thin convenience wrapper over `verify-url`: it constructs
1679
+ * `${PUBLIC_CER_ENDPOINT}?certificate_hash=<hash>` and delegates, so the output is
1680
+ * identical to `verify-url`. The hash must carry the canonical `sha256:` prefix.
1681
+ */
1682
+ export async function verifyHashCommand(certificateHash, opts) {
1683
+ if (typeof certificateHash !== 'string' || !certificateHash.startsWith('sha256:')) {
1684
+ console.error(`[nexart] Error: certificate hash must start with "sha256:" (got "${certificateHash}")`);
1685
+ process.exit(1);
1686
+ }
1687
+ const url = `${PUBLIC_CER_ENDPOINT}?certificate_hash=${encodeURIComponent(certificateHash)}`;
1688
+ return verifyUrlCommand(url, opts);
1689
+ }
1690
+ /** Default location of the node's published Ed25519 signing key set. */
1691
+ const DEFAULT_WELLKNOWN_URL = 'https://node.nexart.io/.well-known/nexart-node.json';
1692
+ /**
1693
+ * Fetch the node's published key set (a NodeKeysDocument) over HTTPS. This is the ONLY
1694
+ * network call in the verify path and lives entirely in the CLI layer — the auditable
1695
+ * core (standaloneVerify.ts) never touches the network. Failure is graceful: any
1696
+ * network/HTTP/timeout error returns `undefined`, and verification falls back to an
1697
+ * explicit --public-key, an embedded key, or a skipped signature. It never throws.
1698
+ */
1699
+ async function fetchNodeKeysDocument(url, timeoutMs = 8000) {
1700
+ try {
1701
+ const controller = new AbortController();
1702
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1703
+ let res;
1704
+ try {
1705
+ res = await fetch(url, { signal: controller.signal, headers: { accept: 'application/json' } });
1706
+ }
1707
+ finally {
1708
+ clearTimeout(timer);
1709
+ }
1710
+ if (!res.ok)
1711
+ return undefined;
1712
+ const text = await res.text();
1713
+ let nodeId;
1714
+ let operator;
1715
+ try {
1716
+ const j = JSON.parse(text);
1717
+ if (typeof j.nodeId === 'string')
1718
+ nodeId = j.nodeId;
1719
+ if (typeof j.operator === 'string')
1720
+ operator = j.operator;
1721
+ }
1722
+ catch {
1723
+ // Not JSON we can summarise; resolvePublicKey will surface any real problem.
1724
+ }
1725
+ return { text, nodeId, operator };
1726
+ }
1727
+ catch {
1728
+ return undefined;
1729
+ }
1730
+ }
1731
+ /** Human-readable label for the Node info "Key source" line. */
1732
+ function keySourceLabel(source) {
1733
+ switch (source) {
1734
+ case 'override':
1735
+ return 'supplied via --public-key';
1736
+ case 'well-known':
1737
+ return 'node.nexart.io (.well-known)';
1738
+ case 'well-known-unreachable':
1739
+ return 'unavailable (.well-known unreachable)';
1740
+ case 'well-known-unusable':
1741
+ return 'unavailable (.well-known has no matching key)';
1742
+ case 'embedded':
1743
+ return 'embedded in bundle';
1744
+ case 'none':
1745
+ default:
1746
+ return 'none';
1747
+ }
1748
+ }
1749
+ /**
1750
+ * Map an RFC 3161 verification status to the verify-record timestamp headline
1751
+ * (VERIFIED / INVALID (<reason>) / NOT INCLUDED). Distinct from formatTimestampLine()
1752
+ * (verify-bundle), whose "Externally anchored timestamp: …" wording is unchanged.
1753
+ */
1754
+ function formatRecordTimestampLine(status) {
1755
+ const CHECK = '\u2714';
1756
+ const CROSS = '\u2716';
1757
+ const WARN = '\u26A0';
1758
+ switch (status) {
1759
+ case 'valid':
1760
+ return `${CHECK} Timestamp: VERIFIED`;
1761
+ case 'invalid-signature':
1762
+ return `${CROSS} Timestamp: INVALID (signature)`;
1763
+ case 'invalid-imprint':
1764
+ return `${CROSS} Timestamp: INVALID (imprint mismatch)`;
1765
+ case 'untrusted-root':
1766
+ return `${CROSS} Timestamp: INVALID (untrusted root)`;
1767
+ case 'expired':
1768
+ return `${CROSS} Timestamp: INVALID (expired)`;
1769
+ case 'invalid-time':
1770
+ return `${CROSS} Timestamp: INVALID (time)`;
1771
+ // A present-but-unverifiable token (parse failure, or verification not attempted) is
1772
+ // NOT a broken record — it is advisory only and never affects FINAL STATUS. Report it
1773
+ // as a caution, not an error, so users don't read it as tampering.
1774
+ case 'malformed':
1775
+ case 'present-unverified':
1776
+ return `${WARN} Timestamp: PRESENT BUT NOT VERIFIED`;
1777
+ case 'not-present':
1778
+ default:
1779
+ return `${CROSS} Timestamp: NOT INCLUDED`;
1780
+ }
1781
+ }
1782
+ /**
1783
+ * Render the redesigned, audit-grade verify-record report (default human-readable, or
1784
+ * --json). Sections: Integrity, Signature, Timestamp, Node, Identity, Execution. This
1785
+ * is verify-record ONLY — renderStandaloneVerification (verify-bundle) is untouched.
1786
+ */
1787
+ function renderRecordVerification(result, tsaResult, ctx) {
1788
+ const snapshot = ctx.bundle.snapshot && typeof ctx.bundle.snapshot === 'object'
1789
+ ? ctx.bundle.snapshot
1790
+ : undefined;
1791
+ const identity = snapshot?.identity && typeof snapshot.identity === 'object'
1792
+ ? snapshot.identity
1793
+ : undefined;
1794
+ const params = snapshot?.parameters && typeof snapshot.parameters === 'object'
1795
+ ? snapshot.parameters
1796
+ : undefined;
1797
+ const str = (v) => (typeof v === 'string' ? v : undefined);
1798
+ const nodeId = ctx.nodeMeta.nodeId ?? str(ctx.receipt?.nodeId);
1799
+ const keyId = str(ctx.receipt?.attestorKeyId);
1800
+ const genTimeIso = tsaResult.genTime ? tsaResult.genTime.replace(/\.\d{3}Z$/, 'Z') : undefined;
1801
+ if (ctx.opts.json) {
1802
+ process.stdout.write(JSON.stringify({
1803
+ status: result.status,
1804
+ integrity: result.integrity,
1805
+ redacted: result.integrity === 'redacted',
1806
+ signature: result.signature,
1807
+ protocolVersion: result.protocolVersion,
1808
+ certificateHash: result.expectedCertificateHash,
1809
+ timestamp: {
1810
+ status: tsaResult.status,
1811
+ ...(tsaResult.tsa ? { tsa: tsaResult.tsa } : {}),
1812
+ ...(genTimeIso ? { genTime: genTimeIso } : {}),
1813
+ ...(tsaResult.reason ? { reason: tsaResult.reason } : {}),
1814
+ },
1815
+ node: {
1816
+ ...(nodeId ? { nodeId } : {}),
1817
+ ...(keyId ? { keyId } : {}),
1818
+ keySource: ctx.keySource,
1819
+ ...(ctx.nodeMeta.operator ? { operator: ctx.nodeMeta.operator } : {}),
1820
+ },
1821
+ identity: identity ?? null,
1822
+ execution: {
1823
+ ...(str(snapshot?.provider) ? { provider: str(snapshot?.provider) } : {}),
1824
+ ...(str(snapshot?.model) ? { model: str(snapshot?.model) } : {}),
1825
+ ...(str(snapshot?.inputHash) ? { inputHash: str(snapshot?.inputHash) } : {}),
1826
+ ...(str(snapshot?.outputHash) ? { outputHash: str(snapshot?.outputHash) } : {}),
1827
+ ...(params ? { parameters: params } : {}),
1828
+ ...(str(ctx.bundle.createdAt) ? { createdAt: str(ctx.bundle.createdAt) } : {}),
1829
+ },
1830
+ reasons: result.reasons,
1831
+ }, null, 2) + '\n');
1832
+ return;
1833
+ }
1834
+ const WARN = '\u26A0';
1835
+ const mark = (p) => p === 'pass' ? '\u2714' : p === 'redacted' ? WARN : p === 'fail' ? '\u2716' : '\u25CB';
1836
+ const label = (p) => p === 'redacted' ? 'NOT RECOMPUTABLE (redacted)' : p.toUpperCase();
1837
+ // --short: a single machine-glanceable verdict line, nothing else. 3-state.
1838
+ if (ctx.opts.short) {
1839
+ console.log(result.status === 'verified'
1840
+ ? '\u2714 VERIFIED'
1841
+ : result.status === 'authentic-redacted'
1842
+ ? `${WARN} AUTHENTIC (REDACTED)`
1843
+ : '\u2716 FAILED');
1844
+ return;
1845
+ }
1846
+ if (ctx.opts.verbose && result.steps.length > 0) {
1847
+ console.log('Verification steps:');
1848
+ for (const s of result.steps)
1849
+ console.log(` - ${s}`);
1850
+ console.log('');
1851
+ }
1852
+ // ── Verdict block ── Signature FIRST: authenticity establishes trust up front,
1853
+ // then integrity (recomputable vs redacted), then the (advisory) timestamp.
1854
+ console.log(`${mark(result.signature)} Signature: ${label(result.signature)}`);
1855
+ console.log(`${mark(result.integrity)} Integrity: ${label(result.integrity)}`);
1856
+ console.log(formatRecordTimestampLine(tsaResult.status));
1857
+ if (tsaResult.status !== 'not-present' && genTimeIso) {
1858
+ console.log(` Time: ${genTimeIso}`);
1859
+ console.log(` Authority: ${deriveTsaProvider(tsaResult.tsa)}`);
1860
+ }
1861
+ // ── Node info ──
1862
+ console.log('');
1863
+ console.log('Node');
1864
+ console.log(` Node ID: ${nodeId ?? '(unknown)'}`);
1865
+ console.log(` Key ID: ${keyId ?? '(unknown)'}`);
1866
+ console.log(` Key source: ${keySourceLabel(ctx.keySource)}`);
1867
+ if (ctx.nodeMeta.operator)
1868
+ console.log(` Operator: ${ctx.nodeMeta.operator}`);
1869
+ // ── Identity summary ──
1870
+ console.log('');
1871
+ console.log('Identity');
1872
+ if (identity) {
1873
+ for (const [k, v] of Object.entries(identity)) {
1874
+ console.log(` ${k}: ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`);
1875
+ }
1876
+ }
1877
+ else {
1878
+ console.log(' (none asserted)');
1879
+ }
1880
+ // ── Execution summary ──
1881
+ console.log('');
1882
+ console.log('Execution');
1883
+ console.log(` Protocol: ${result.protocolVersion}`);
1884
+ if (str(snapshot?.provider))
1885
+ console.log(` Provider: ${str(snapshot?.provider)}`);
1886
+ if (str(snapshot?.model))
1887
+ console.log(` Model: ${str(snapshot?.model)}`);
1888
+ if (str(snapshot?.inputHash))
1889
+ console.log(` Input: ${str(snapshot?.inputHash)}`);
1890
+ if (str(snapshot?.outputHash))
1891
+ console.log(` Output: ${str(snapshot?.outputHash)}`);
1892
+ if (params) {
1893
+ const pretty = Object.entries(params)
1894
+ .map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
1895
+ .join(', ');
1896
+ if (pretty)
1897
+ console.log(` Parameters: ${pretty}`);
1898
+ }
1899
+ if (str(ctx.bundle.createdAt))
1900
+ console.log(` Created: ${str(ctx.bundle.createdAt)}`);
1901
+ // ── Final verdict line (3-state) ──
1902
+ console.log('');
1903
+ console.log(result.status === 'verified'
1904
+ ? 'FINAL STATUS: VERIFIED EVIDENCE RECORD'
1905
+ : result.status === 'authentic-redacted'
1906
+ ? 'FINAL STATUS: AUTHENTIC (REDACTED)'
1907
+ : 'FINAL STATUS: FAILED');
1908
+ // Context block — ONLY for the redacted case. Kept tight against FINAL STATUS so it
1909
+ // reads as one unit: why integrity could not be recomputed, who applied the redaction,
1910
+ // and that the record is nonetheless authentic (not an error).
1911
+ if (result.status === 'authentic-redacted') {
1912
+ console.log('Mode: Public Verification (Redacted)');
1913
+ console.log('Redaction: Applied by certification node (privacy mode)');
1914
+ console.log('! Full integrity verification requires access to original execution data');
1915
+ }
1916
+ // ── Failure reasons (stderr) ──
1917
+ if (result.status === 'failed' || result.reasons.length > 0) {
1918
+ for (const r of result.reasons)
1919
+ console.error(` ! ${r}`);
1920
+ }
1921
+ if (ctx.opts.verbose && tsaResult.reason && tsaResult.status !== 'valid') {
1922
+ console.error(` ! Timestamp: ${tsaResult.reason}`);
1923
+ }
1924
+ }
1554
1925
  /**
1555
1926
  * Locate an RFC 3161 timestamp in a CER bundle WITHOUT mutating it. Supports both
1556
1927
  * the canonical `trustedTimestamps[]` array (each { type:'rfc3161', tsa, timestamp,
@@ -2170,50 +2541,166 @@ if (_isMainModule) {
2170
2541
  tsaRoots: argv['tsa-roots'],
2171
2542
  });
2172
2543
  })
2173
- .command('verify-record <file>', 'Verify a full CER record (bundle + signature + externally anchored timestamp) offline', (yargs) => {
2544
+ .command('verify-record <file>', 'Verify a full CER record automatically: integrity, receipt signature, and RFC 3161 timestamp', (yargs) => {
2174
2545
  return yargs
2175
2546
  .positional('file', {
2176
- describe: 'Path to the CER record JSON file ({ bundle, verification, timestamp? })',
2547
+ describe: 'Path to the CER record JSON, or "-" to read it from stdin ({ bundle, verification?, timestamp? } or a bare bundle)',
2177
2548
  type: 'string',
2178
2549
  demandOption: true,
2179
2550
  })
2180
2551
  .option('public-key', {
2181
- describe: 'Path to the Ed25519 public key (PEM, JWK, NodeKeysDocument, or raw base64url)',
2552
+ describe: 'Override the signing key (PEM, JWK, NodeKeysDocument, or raw base64url). Default: auto-fetched from the node .well-known key set',
2182
2553
  type: 'string',
2554
+ })
2555
+ .option('offline', {
2556
+ describe: 'Do not contact the network; use --public-key or a bundle-embedded key only',
2557
+ type: 'boolean',
2558
+ default: false,
2183
2559
  })
2184
2560
  .option('kid', {
2185
- describe: 'Key id to select when --public-key is a NodeKeysDocument',
2561
+ describe: 'Key id to select from the key set (default: the receipt attestorKeyId)',
2186
2562
  type: 'string',
2187
2563
  })
2188
2564
  .option('json', {
2189
2565
  describe: 'Output machine-readable JSON',
2190
2566
  type: 'boolean',
2191
2567
  default: false,
2568
+ })
2569
+ .option('short', {
2570
+ describe: 'Print only a single verdict line (✔ VERIFIED / ⚠ AUTHENTIC (REDACTED) / ✖ FAILED)',
2571
+ type: 'boolean',
2572
+ default: false,
2192
2573
  })
2193
2574
  .option('verbose', {
2194
2575
  describe: 'Show intermediate verification steps',
2195
2576
  type: 'boolean',
2196
2577
  default: false,
2197
2578
  })
2198
- .option('verify-timestamp', {
2199
- describe: 'Additionally verify the externally anchored RFC 3161 timestamp (offline; never affects the exit code)',
2579
+ .option('tsa-roots', {
2580
+ describe: 'Override the default DigiCert TSA trust roots with custom root certificate(s) (PEM)',
2581
+ type: 'string',
2582
+ })
2583
+ .example('$0 verify-record record.json', 'Full automatic verification (auto-fetch key, verify timestamp)')
2584
+ .example('$0 verify-record record.json --json', 'Machine-readable verification report')
2585
+ .example('curl -s https://node/record.json | $0 verify-record -', 'Verify a record piped from stdin')
2586
+ .example('$0 verify-record record.json --offline --public-key node-keys.json', 'Fully offline verification with an explicit key');
2587
+ }, async (argv) => {
2588
+ // yargs-parser collapses a bare "-" positional to an empty string; restore the
2589
+ // stdin sentinel so `verify-record -` reads the record from stdin.
2590
+ const file = argv.file === '' ? '-' : argv.file;
2591
+ await verifyRecordStandaloneCommand(file, {
2592
+ publicKey: argv['public-key'],
2593
+ kid: argv.kid,
2594
+ json: argv.json,
2595
+ short: argv.short,
2596
+ verbose: argv.verbose,
2597
+ offline: argv.offline,
2598
+ tsaRoots: argv['tsa-roots'],
2599
+ });
2600
+ })
2601
+ .command('verify-hash <certificate_hash>', 'Resolve a certificate hash to its public CER record and verify it — one-command verification', (yargs) => {
2602
+ return yargs
2603
+ .positional('certificate_hash', {
2604
+ describe: 'CER certificate hash (must include the "sha256:" prefix)',
2605
+ type: 'string',
2606
+ demandOption: true,
2607
+ })
2608
+ .option('public-key', {
2609
+ describe: 'Override the signing key (PEM, JWK, NodeKeysDocument, or raw base64url). Default: auto-fetched from the node .well-known key set',
2610
+ type: 'string',
2611
+ })
2612
+ .option('offline', {
2613
+ describe: 'Do not auto-fetch the signing key (the record itself is still fetched); use --public-key or a bundle-embedded key only',
2614
+ type: 'boolean',
2615
+ default: false,
2616
+ })
2617
+ .option('kid', {
2618
+ describe: 'Key id to select from the key set (default: the receipt attestorKeyId)',
2619
+ type: 'string',
2620
+ })
2621
+ .option('json', {
2622
+ describe: 'Output machine-readable JSON',
2623
+ type: 'boolean',
2624
+ default: false,
2625
+ })
2626
+ .option('short', {
2627
+ describe: 'Print only a single verdict line (✔ VERIFIED / ⚠ AUTHENTIC (REDACTED) / ✖ FAILED)',
2628
+ type: 'boolean',
2629
+ default: false,
2630
+ })
2631
+ .option('verbose', {
2632
+ describe: 'Show intermediate verification steps',
2200
2633
  type: 'boolean',
2201
2634
  default: false,
2202
2635
  })
2203
2636
  .option('tsa-roots', {
2204
- describe: 'Path to trusted TSA root certificate(s) (PEM) for --verify-timestamp',
2637
+ describe: 'Override the default DigiCert TSA trust roots with custom root certificate(s) (PEM)',
2205
2638
  type: 'string',
2206
2639
  })
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');
2640
+ .example('$0 verify-hash sha256:abc123…', 'Resolve and fully verify a record by certificate hash')
2641
+ .example('$0 verify-hash sha256:abc123… --short', 'One-line PASS/FAIL verdict')
2642
+ .example('$0 verify-hash sha256:abc123… --json', 'Machine-readable verification report');
2210
2643
  }, async (argv) => {
2211
- await verifyRecordStandaloneCommand(argv.file, {
2644
+ await verifyHashCommand(argv.certificate_hash, {
2212
2645
  publicKey: argv['public-key'],
2213
2646
  kid: argv.kid,
2214
2647
  json: argv.json,
2648
+ short: argv.short,
2215
2649
  verbose: argv.verbose,
2216
- verifyTimestamp: argv['verify-timestamp'],
2650
+ offline: argv.offline,
2651
+ tsaRoots: argv['tsa-roots'],
2652
+ });
2653
+ })
2654
+ .command('verify-url <url>', 'Fetch a CER record (or bundle) from a URL and verify it automatically — one-command verification', (yargs) => {
2655
+ return yargs
2656
+ .positional('url', {
2657
+ describe: 'URL returning a CER record or bundle as JSON',
2658
+ type: 'string',
2659
+ demandOption: true,
2660
+ })
2661
+ .option('public-key', {
2662
+ describe: 'Override the signing key (PEM, JWK, NodeKeysDocument, or raw base64url). Default: auto-fetched from the node .well-known key set',
2663
+ type: 'string',
2664
+ })
2665
+ .option('offline', {
2666
+ describe: 'Do not auto-fetch the signing key (the record URL itself is still fetched); use --public-key or a bundle-embedded key only',
2667
+ type: 'boolean',
2668
+ default: false,
2669
+ })
2670
+ .option('kid', {
2671
+ describe: 'Key id to select from the key set (default: the receipt attestorKeyId)',
2672
+ type: 'string',
2673
+ })
2674
+ .option('json', {
2675
+ describe: 'Output machine-readable JSON',
2676
+ type: 'boolean',
2677
+ default: false,
2678
+ })
2679
+ .option('short', {
2680
+ describe: 'Print only a single verdict line (✔ VERIFIED / ⚠ AUTHENTIC (REDACTED) / ✖ FAILED)',
2681
+ type: 'boolean',
2682
+ default: false,
2683
+ })
2684
+ .option('verbose', {
2685
+ describe: 'Show intermediate verification steps',
2686
+ type: 'boolean',
2687
+ default: false,
2688
+ })
2689
+ .option('tsa-roots', {
2690
+ describe: 'Override the default DigiCert TSA trust roots with custom root certificate(s) (PEM)',
2691
+ type: 'string',
2692
+ })
2693
+ .example('$0 verify-url https://node.example/records/abc.json', 'Fetch and fully verify a record')
2694
+ .example('$0 verify-url https://node.example/records/abc.json --short', 'One-line PASS/FAIL verdict')
2695
+ .example('$0 verify-url https://node.example/records/abc.json --json', 'Machine-readable verification report');
2696
+ }, async (argv) => {
2697
+ await verifyUrlCommand(argv.url, {
2698
+ publicKey: argv['public-key'],
2699
+ kid: argv.kid,
2700
+ json: argv.json,
2701
+ short: argv.short,
2702
+ verbose: argv.verbose,
2703
+ offline: argv.offline,
2217
2704
  tsaRoots: argv['tsa-roots'],
2218
2705
  });
2219
2706
  })