@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/CHANGELOG.md +64 -0
- package/README.md +129 -24
- package/dist/index.d.ts +49 -14
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +533 -69
- package/dist/index.js.map +1 -1
- package/dist/trustRoots.d.ts +6 -0
- package/dist/trustRoots.d.ts.map +1 -0
- package/dist/trustRoots.js +84 -0
- package/dist/trustRoots.js.map +1 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1480
|
-
*
|
|
1481
|
-
*
|
|
1482
|
-
*
|
|
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
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
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
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
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(
|
|
1558
|
+
const result = verifyBundleStandalone(verifyInput, {
|
|
1532
1559
|
publicKeyMaterial,
|
|
1533
|
-
kid:
|
|
1560
|
+
kid: attestorKeyId,
|
|
1534
1561
|
verbose: opts.verbose,
|
|
1535
1562
|
});
|
|
1536
|
-
//
|
|
1537
|
-
//
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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: '
|
|
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
|
|
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('
|
|
2199
|
-
describe: '
|
|
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: '
|
|
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-
|
|
2208
|
-
.example('$0 verify-
|
|
2209
|
-
.example('$0 verify-
|
|
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
|
|
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
|
-
|
|
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
|
})
|