verifyhash 0.1.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/LICENSE +201 -0
- package/README.md +883 -0
- package/cli/abi/ContributionRegistry.json +881 -0
- package/cli/agent.js +2173 -0
- package/cli/anchor-artifact.js +853 -0
- package/cli/anchor.js +400 -0
- package/cli/claim.js +881 -0
- package/cli/core/agent-commit.js +448 -0
- package/cli/core/agent-session.js +598 -0
- package/cli/core/anchor-binding.js +663 -0
- package/cli/core/attestation.js +580 -0
- package/cli/core/evidence-plans.js +495 -0
- package/cli/core/fixtures/evidence-plans/baseline.json +19 -0
- package/cli/core/fulfill-intake.js +1082 -0
- package/cli/core/go-live-preflight.js +481 -0
- package/cli/core/license.js +534 -0
- package/cli/core/manifest.js +243 -0
- package/cli/core/packetseal.js +591 -0
- package/cli/core/registryArtifact.js +49 -0
- package/cli/core/revocation.js +539 -0
- package/cli/core/rfc3161.js +389 -0
- package/cli/core/timestamp.js +482 -0
- package/cli/core/trust-asof.js +479 -0
- package/cli/dataset.js +2950 -0
- package/cli/evidence.js +2227 -0
- package/cli/fulfill-webhook-http.js +438 -0
- package/cli/git.js +220 -0
- package/cli/hash.js +550 -0
- package/cli/identity.js +1072 -0
- package/cli/journal-cli.js +1110 -0
- package/cli/journal-log.js +454 -0
- package/cli/journal.js +334 -0
- package/cli/lineage.js +447 -0
- package/cli/list.js +287 -0
- package/cli/parcel.js +1509 -0
- package/cli/proof.js +578 -0
- package/cli/prove.js +300 -0
- package/cli/receipt.js +631 -0
- package/cli/registry.js +331 -0
- package/cli/reputation.js +344 -0
- package/cli/revocation.js +495 -0
- package/cli/serve-verify-http.js +298 -0
- package/cli/serve-verify.js +333 -0
- package/cli/show.js +339 -0
- package/cli/verify.js +383 -0
- package/cli/vh.js +3927 -0
- package/docs/ADOPT.md +183 -0
- package/docs/ADOPTION.json +11 -0
- package/docs/AGENTTRACE.md +247 -0
- package/docs/ANCHORING.md +167 -0
- package/docs/AUDIT.md +55 -0
- package/docs/CONFORMANCE.md +107 -0
- package/docs/DATALEDGER.md +638 -0
- package/docs/DECIDE.md +47 -0
- package/docs/DECISIONS-PENDING.md +27 -0
- package/docs/DEPLOY-PUBLIC-SITE.md +301 -0
- package/docs/ENGINE-LEDGER.json +12 -0
- package/docs/EVIDENCE.md +519 -0
- package/docs/GO-LIVE.md +66 -0
- package/docs/IDENTITY.md +123 -0
- package/docs/INDEPENDENT-VERIFICATION.md +377 -0
- package/docs/INTEGRITY-JOURNAL.md +337 -0
- package/docs/KEY-LIFECYCLE.md +179 -0
- package/docs/LICENSING.md +46 -0
- package/docs/LINEAGE.md +307 -0
- package/docs/LOOP-AUDIT-2026-07-03.json +580 -0
- package/docs/LOOP-HARDENING-PLAN.md +44 -0
- package/docs/MERKLE-LEAVES.md +113 -0
- package/docs/METRICS.jsonl +31 -0
- package/docs/MORNING.md +204 -0
- package/docs/PILOT.md +444 -0
- package/docs/PROOFPARCEL.md +227 -0
- package/docs/PROOFS.md +262 -0
- package/docs/RECEIPTS.md +341 -0
- package/docs/REPUTATION.md +158 -0
- package/docs/SDK.md +301 -0
- package/docs/STRATEGY-ARCHIVE.md +5055 -0
- package/docs/SUPERVISOR-RUNBOOK.md +52 -0
- package/docs/TRUST-BOUNDARIES.md +335 -0
- package/docs/TRUSTLEDGER.md +1976 -0
- package/docs/USAGE-BUDGET.json +121 -0
- package/docs/VERIFY-SERVICE.md +168 -0
- package/index.js +160 -0
- package/package.json +41 -0
- package/trustledger/build-standalone.js +796 -0
- package/trustledger/cli.js +3179 -0
- package/trustledger/close.js +391 -0
- package/trustledger/corpus.js +159 -0
- package/trustledger/dist/BUILD-PROVENANCE.json +99 -0
- package/trustledger/dist/trustledger-standalone.html +6197 -0
- package/trustledger/dist/trustledger-standalone.html.sha256 +1 -0
- package/trustledger/door-core.js +442 -0
- package/trustledger/fixtures/bank.csv +7 -0
- package/trustledger/fixtures/bank.malformed.csv +3 -0
- package/trustledger/fixtures/bank.noalias.csv +5 -0
- package/trustledger/fixtures/bank.ofx +34 -0
- package/trustledger/fixtures/bank.real.csv +5 -0
- package/trustledger/fixtures/corpus/_shared/prior-close.json +22 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/inputs.json +14 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/inputs.json +14 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/continuity-break--benign-twin/inputs.json +15 -0
- package/trustledger/fixtures/corpus/continuity-break--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/continuity-break--out-of-trust/inputs.json +15 -0
- package/trustledger/fixtures/corpus/continuity-break--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/inputs.json +13 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/inputs.json +15 -0
- package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/inputs.json +15 -0
- package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/inputs.json +16 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/inputs.json +13 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/e2e/bank.aliased.csv +4 -0
- package/trustledger/fixtures/e2e/bank.csv +4 -0
- package/trustledger/fixtures/e2e/bank.nsf.csv +4 -0
- package/trustledger/fixtures/e2e/quickbooks.csv +6 -0
- package/trustledger/fixtures/e2e/quickbooks.nsf.csv +8 -0
- package/trustledger/fixtures/e2e/rentroll.csv +6 -0
- package/trustledger/fixtures/e2e/rentroll.nsf.csv +8 -0
- package/trustledger/fixtures/e2e/rentroll.short.csv +5 -0
- package/trustledger/fixtures/plans/baseline.json +25 -0
- package/trustledger/fixtures/plans/price-binding.example.json +27 -0
- package/trustledger/fixtures/policy/ambiguous-deposit-example.json +12 -0
- package/trustledger/fixtures/policy/baseline.json +19 -0
- package/trustledger/fixtures/policy/ca-example.json +12 -0
- package/trustledger/fixtures/policy/negative-tenant-ledger-example.json +12 -0
- package/trustledger/fixtures/policy/owner-overdraw-example.json +12 -0
- package/trustledger/fixtures/quickbooks.csv +7 -0
- package/trustledger/fixtures/quickbooks.real.csv +5 -0
- package/trustledger/fixtures/rentroll.csv +6 -0
- package/trustledger/fixtures/rentroll.real.csv +4 -0
- package/trustledger/ingest.js +1163 -0
- package/trustledger/lib/policy-bundled-loader.js +44 -0
- package/trustledger/lib/sha256-vendored.js +227 -0
- package/trustledger/license.js +563 -0
- package/trustledger/match.js +551 -0
- package/trustledger/plans.js +551 -0
- package/trustledger/policy.js +398 -0
- package/trustledger/public/index.html +512 -0
- package/trustledger/reconcile.js +1486 -0
- package/trustledger/report.js +887 -0
- package/trustledger/seal.js +854 -0
- package/trustledger/server.js +391 -0
- package/trustledger/valueproof.js +350 -0
package/cli/identity.js
ADDED
|
@@ -0,0 +1,1072 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// cli/identity.js — the PURE producer-IDENTITY core for verifyhash (EPIC-49 / T-49.1).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS
|
|
6
|
+
// Every sealed/signed artifact this family mints (an evidence seal, a signed license, a dataset
|
|
7
|
+
// attestation) pins its producer by a vendor ADDRESS the recipient must learn OUT OF BAND — an email,
|
|
8
|
+
// a slide, a README line. A cold prospect therefore has NO first-class, OFFLINE-verifiable way to
|
|
9
|
+
// answer "does this 0x-address really belong to THIS vendor, and what exactly do they attest — and,
|
|
10
|
+
// just as load-bearing, what do they explicitly NOT attest?". The producer IDENTITY CARD closes that
|
|
11
|
+
// gap: a vendor SIGNS, with the SAME key that signs their evidence/licenses, a small self-describing
|
|
12
|
+
// container that binds their `vendorAddress` to a bounded `claims[]` set and an honest `nonClaims[]`
|
|
13
|
+
// set. A recipient who holds the card can recover the signer, confirm it equals the card's OWN
|
|
14
|
+
// `vendorAddress` (the key controls the address it claims), and OPTIONALLY pin it to an address they
|
|
15
|
+
// learned out of band — all OFFLINE, no network, no key, no I/O.
|
|
16
|
+
//
|
|
17
|
+
// IT IS JUST ONE MORE PRODUCT ON THE SHARED SIGNED-ATTESTATION ENVELOPE.
|
|
18
|
+
// Exactly like the seal/license/dataset, the identity card defines an UNSIGNED PAYLOAD (a versioned
|
|
19
|
+
// object: vendorAddress + a CLOSED productLine + the bounded claims[]/nonClaims[] + publishedAt), a
|
|
20
|
+
// canonical serializer, and a strict validator, then hands those to `cli/core/attestation.js` as the
|
|
21
|
+
// product framing. The attestation core does ALL the crypto: it embeds the EXACT canonical payload
|
|
22
|
+
// bytes as the `attestation`, attaches the detached EIP-191 signature, and later RE-DERIVES the signer
|
|
23
|
+
// from those bytes. There is NO new crypto here, NO new dependency, NO new scheme — `buildIdentityCard`
|
|
24
|
+
// wraps via `signAttestation`, `verifyIdentityCard` recovers via `verifySignedAttestation`, byte-for-byte
|
|
25
|
+
// the SAME shared paths the seal uses.
|
|
26
|
+
//
|
|
27
|
+
// THE LOAD-BEARING EXTRA CHECK — the key controls the address it claims.
|
|
28
|
+
// A license pins the signer to a caller-supplied `vendorAddress`. An identity card carries its OWN
|
|
29
|
+
// `vendorAddress` INSIDE the signed payload, so the card asserts "the holder of this key IS this
|
|
30
|
+
// address". `verifyIdentityCard` therefore REQUIRES recovered === the embedded `vendorAddress`: a card
|
|
31
|
+
// whose signature recovers to any OTHER key is REJECTED (`vendorAddressMatchesSigner` fails), never a
|
|
32
|
+
// silent accept. `buildIdentityCard` enforces the SAME invariant at mint time — it refuses to produce a
|
|
33
|
+
// card for an address the provisioned signer does not control — so a card can never round-trip into a
|
|
34
|
+
// false ACCEPT.
|
|
35
|
+
//
|
|
36
|
+
// PURE + I/O-FREE + KEY-AGNOSTIC.
|
|
37
|
+
// Every function here is pure: no filesystem, no clock, no network. The only key handling is a passed-in
|
|
38
|
+
// ethers signer-like object (an ephemeral `Wallet.createRandom()` in tests; the loop NEVER holds a real
|
|
39
|
+
// key) whose private key lives ONLY inside that object — never read, persisted, or logged here. The
|
|
40
|
+
// `publishedAt` instant is a CALLER-supplied argument; this core never reads the system clock, so the
|
|
41
|
+
// same inputs always yield byte-identical bytes + verdict. PRODUCT-AGNOSTIC: this module requires the
|
|
42
|
+
// GENERIC attestation core, never the reverse — no back-edge.
|
|
43
|
+
|
|
44
|
+
const fs = require("fs");
|
|
45
|
+
const path = require("path");
|
|
46
|
+
const coreAttestation = require("./core/attestation");
|
|
47
|
+
const coreTrustAsOf = require("./core/trust-asof");
|
|
48
|
+
const { getAddress, isAddress } = require("ethers");
|
|
49
|
+
|
|
50
|
+
// On-disk schema discriminators. The identity card carries its OWN kind + version (distinct from every
|
|
51
|
+
// seal/license/manifest kind) so a random JSON file, a license, or a seal is never misread as a card.
|
|
52
|
+
const IDENTITY_CARD_KIND = "vh-identity-card";
|
|
53
|
+
const IDENTITY_CARD_SCHEMA_VERSION = 1;
|
|
54
|
+
const SUPPORTED_IDENTITY_CARD_SCHEMA_VERSIONS = Object.freeze([1]);
|
|
55
|
+
|
|
56
|
+
// The SIGNED-container framing (the detached-signature envelope kind) — its OWN discriminator.
|
|
57
|
+
const SIGNED_IDENTITY_CARD_KIND = "vh-identity-card-signed";
|
|
58
|
+
const SIGNED_IDENTITY_CARD_SCHEMA_VERSION = 1;
|
|
59
|
+
const SUPPORTED_SIGNED_IDENTITY_CARD_SCHEMA_VERSIONS = Object.freeze([1]);
|
|
60
|
+
|
|
61
|
+
// The CLOSED productLine set. A card declares WHICH product family the vendor publishes under; an
|
|
62
|
+
// out-of-set value is a HARD build/validate error (never silently honored), exactly like the license
|
|
63
|
+
// core's closed entitlement table. Frozen + a derived sorted list so error messages are deterministic.
|
|
64
|
+
const PRODUCT_LINES = Object.freeze(["evidence", "dataledger", "trustledger"]);
|
|
65
|
+
const PRODUCT_LINE_SET = Object.freeze(PRODUCT_LINES.slice().sort());
|
|
66
|
+
|
|
67
|
+
// A claimed 0x-address INSIDE the payload: 0x + 40 LOWERCASE hex chars. Lowercase-only for the SAME
|
|
68
|
+
// byte-determinism reason the attestation core lowercases the signer — an EIP-55-checksummed address is
|
|
69
|
+
// the canonical address in a DIFFERENT encoding, so accepting it verbatim would let one vendor serialize
|
|
70
|
+
// two ways. A caller holding a checksummed address lowercases it before building the card (buildIdentityCard
|
|
71
|
+
// normalizes for them via getAddress, so a checksummed input is accepted and canonicalized).
|
|
72
|
+
const ADDRESS_RE = /^0x[0-9a-f]{40}$/;
|
|
73
|
+
|
|
74
|
+
// A strict ISO-8601 UTC instant ("YYYY-MM-DDTHH:MM:SS(.mmm)Z"). Same canonical instant grammar the license
|
|
75
|
+
// core pins issuedAt/expiresAt to, so two logically-identical cards serialize to identical bytes. We pin
|
|
76
|
+
// the SHAPE here and require the canonical millis round-trip below.
|
|
77
|
+
const ISO_INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
|
|
78
|
+
|
|
79
|
+
// The in-band trust caveat — stated ONCE so the human + JSON paths agree and the boundary can never drift.
|
|
80
|
+
// It is the load-bearing honesty of the artifact: a card proves IDENTITY + a bounded claim SET; it is NOT
|
|
81
|
+
// a per-packet truth claim, NOT a trusted timestamp, NOT a legal opinion.
|
|
82
|
+
const IDENTITY_CARD_TRUST_NOTE =
|
|
83
|
+
"This is a verifyhash producer IDENTITY CARD: the holder of `vendorAddress`'s key SIGNED it, binding " +
|
|
84
|
+
"that address to the `claims` it attests and the `nonClaims` it explicitly does NOT. verify RE-DERIVES " +
|
|
85
|
+
"the signer from these exact bytes and REQUIRES it to equal `vendorAddress` — it never trusts the file's " +
|
|
86
|
+
"own claims. It proves IDENTITY + the claim SET ONLY: it does NOT prove any specific sealed/signed packet " +
|
|
87
|
+
'is true (each packet carries its own proof), it is NOT a trusted TIMESTAMP ("published since T" rides ' +
|
|
88
|
+
"the human-owned signing/timestamp trust-root, STRATEGY.md P-3), and it is NOT a legal opinion.";
|
|
89
|
+
|
|
90
|
+
const SIGNED_IDENTITY_CARD_TRUST_NOTE =
|
|
91
|
+
"This is a SIGNED verifyhash identity-card container: it WRAPS (never edits) the EXACT canonical " +
|
|
92
|
+
"identity-card bytes in `attestation` and attaches a detached EIP-191 signature. verifyIdentityCard " +
|
|
93
|
+
"RE-DERIVES the signer from those bytes and pins it to the embedded `vendorAddress` — it never trusts " +
|
|
94
|
+
"the file's own claims. Every caveat of the embedded card applies. " +
|
|
95
|
+
IDENTITY_CARD_TRUST_NOTE;
|
|
96
|
+
|
|
97
|
+
// A dedicated error type so callers/tests catch ONE identity error for the HARD validation failures
|
|
98
|
+
// (a closed-field/closed-productLine/empty-claims violation, an out-of-control mint). An ordinary verify
|
|
99
|
+
// REJECT is NOT thrown — it is a clean verdict (verdict: "REJECTED"), exactly like the rest of the family.
|
|
100
|
+
class IdentityCardError extends Error {
|
|
101
|
+
constructor(message) {
|
|
102
|
+
super(message);
|
|
103
|
+
this.name = "IdentityCardError";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isPlainObject(v) {
|
|
108
|
+
return v != null && typeof v === "object" && !Array.isArray(v);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The CLOSED field set of an UNSIGNED identity-card payload. An UNKNOWN/extraneous key is a HARD error
|
|
112
|
+
// (never silently dropped) so the card's shape ossifies with exactly these fields. `kind`/`schemaVersion`/
|
|
113
|
+
// `note` are the framing the core fixes; the rest is the producer-supplied identity.
|
|
114
|
+
const IDENTITY_CARD_FIELDS = Object.freeze([
|
|
115
|
+
"kind",
|
|
116
|
+
"schemaVersion",
|
|
117
|
+
"note",
|
|
118
|
+
"vendorAddress",
|
|
119
|
+
"productLine",
|
|
120
|
+
"claims",
|
|
121
|
+
"nonClaims",
|
|
122
|
+
"publishedAt",
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* STRICT structural validation of an UNSIGNED identity-card payload. Throws an IdentityCardError on the
|
|
127
|
+
* FIRST problem (named + localized); returns the object unchanged on success. NEVER half-accepts and NEVER
|
|
128
|
+
* fills defaults. This is the `validateUnsigned` the attestation core re-runs on the embedded payload (the
|
|
129
|
+
* wrap-don't-edit invariant), so a signed container can never smuggle a malformed/edited card.
|
|
130
|
+
*
|
|
131
|
+
* REJECTS (HARD): a non-object; a wrong kind/schemaVersion/note; an UNKNOWN/extraneous field; a missing/
|
|
132
|
+
* malformed (not lowercase-0x) vendorAddress; an out-of-set productLine; a non-array OR EMPTY claims;
|
|
133
|
+
* a non-array OR EMPTY nonClaims; any claim/nonClaim that is not a non-empty string OR a duplicate; a
|
|
134
|
+
* non-canonical-ISO publishedAt.
|
|
135
|
+
*
|
|
136
|
+
* @param {any} obj
|
|
137
|
+
* @returns {object} the same object, if valid
|
|
138
|
+
*/
|
|
139
|
+
function validateIdentityCard(obj) {
|
|
140
|
+
if (!isPlainObject(obj)) {
|
|
141
|
+
throw new IdentityCardError("identity card payload must be a JSON object");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// CLOSED FIELD SET: every key must be one of IDENTITY_CARD_FIELDS. An unknown/extraneous key HARD-errors
|
|
145
|
+
// (never silently kept) so the card can never carry a smuggled, unvalidated field.
|
|
146
|
+
for (const key of Object.keys(obj)) {
|
|
147
|
+
if (!IDENTITY_CARD_FIELDS.includes(key)) {
|
|
148
|
+
throw new IdentityCardError(
|
|
149
|
+
`identity card has an unknown field: ${JSON.stringify(key)} ` +
|
|
150
|
+
`(the closed field set is ${JSON.stringify(IDENTITY_CARD_FIELDS)})`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (obj.kind !== IDENTITY_CARD_KIND) {
|
|
156
|
+
throw new IdentityCardError(
|
|
157
|
+
`not a verifyhash identity card (kind: ${JSON.stringify(obj.kind)}; expected ${JSON.stringify(IDENTITY_CARD_KIND)})`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
if (!SUPPORTED_IDENTITY_CARD_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
|
|
161
|
+
throw new IdentityCardError(
|
|
162
|
+
`unsupported identity card schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
|
|
163
|
+
`(this build understands ${JSON.stringify(SUPPORTED_IDENTITY_CARD_SCHEMA_VERSIONS)})`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (obj.note !== IDENTITY_CARD_TRUST_NOTE) {
|
|
167
|
+
throw new IdentityCardError(
|
|
168
|
+
"identity card `note` must be the standing IDENTITY_CARD_TRUST_NOTE (caveat must not drift)"
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// vendorAddress — the address the card BINDS to the signing key. A lowercase 0x-address (checksummed/
|
|
173
|
+
// mixed-case is rejected here for byte-determinism; buildIdentityCard lowercases a checksummed input).
|
|
174
|
+
if (typeof obj.vendorAddress !== "string" || !ADDRESS_RE.test(obj.vendorAddress)) {
|
|
175
|
+
throw new IdentityCardError(
|
|
176
|
+
"identity card vendorAddress must be a 0x-prefixed 20-byte LOWERCASE-hex address " +
|
|
177
|
+
`(checksummed/mixed-case rejected for byte-determinism — lowercase it first), got: ${String(obj.vendorAddress)}`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// productLine — a single value drawn from the CLOSED PRODUCT_LINES set. An out-of-set value HARD-errors.
|
|
182
|
+
if (typeof obj.productLine !== "string" || !PRODUCT_LINE_SET.includes(obj.productLine)) {
|
|
183
|
+
throw new IdentityCardError(
|
|
184
|
+
`identity card productLine must be one of the closed set ${JSON.stringify(PRODUCT_LINE_SET)}, ` +
|
|
185
|
+
`got: ${JSON.stringify(obj.productLine)}`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// claims / nonClaims — each a NON-EMPTY array of unique, non-empty strings. An empty array is a HARD
|
|
190
|
+
// error: a card with no claims attests nothing, and a card with no nonClaims drops the load-bearing
|
|
191
|
+
// honest boundary. Validated by the SAME helper so both stay byte-identical.
|
|
192
|
+
_validateStringList(obj.claims, "claims");
|
|
193
|
+
_validateStringList(obj.nonClaims, "nonClaims");
|
|
194
|
+
|
|
195
|
+
// publishedAt — a strict, CANONICAL ISO-8601 UTC instant. The regex pins the SHAPE; the millis round-trip
|
|
196
|
+
// FORCES the `.mmm` form and REJECTS every rolled-over/impossible instant (e.g. Feb-29 in a non-leap year),
|
|
197
|
+
// exactly like the license core, so a self-asserted date can never silently coerce.
|
|
198
|
+
if (typeof obj.publishedAt !== "string" || !ISO_INSTANT_RE.test(obj.publishedAt)) {
|
|
199
|
+
throw new IdentityCardError(
|
|
200
|
+
`identity card publishedAt must be an ISO-8601 UTC instant ("YYYY-MM-DDTHH:MM:SS(.mmm)Z"), got: ${String(obj.publishedAt)}`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
const ms = Date.parse(obj.publishedAt);
|
|
204
|
+
if (Number.isNaN(ms) || new Date(ms).toISOString() !== obj.publishedAt) {
|
|
205
|
+
throw new IdentityCardError(
|
|
206
|
+
`identity card publishedAt must be a canonical ISO-8601 UTC instant ("YYYY-MM-DDTHH:MM:SS.mmmZ", ` +
|
|
207
|
+
`millis required, no rolled-over/impossible fields), got: ${String(obj.publishedAt)}`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return obj;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Internal: validate a `claims`/`nonClaims` list — a NON-EMPTY array of unique, non-empty strings. The
|
|
216
|
+
* field NAME parameterizes the error so each list localizes its own failure. HARD-errors (IdentityCardError).
|
|
217
|
+
*/
|
|
218
|
+
function _validateStringList(list, fieldName) {
|
|
219
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
220
|
+
throw new IdentityCardError(
|
|
221
|
+
`identity card ${fieldName} must be a non-empty array of strings`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
const seen = new Set();
|
|
225
|
+
for (const item of list) {
|
|
226
|
+
if (typeof item !== "string" || item.length === 0) {
|
|
227
|
+
throw new IdentityCardError(
|
|
228
|
+
`identity card ${fieldName} entry must be a non-empty string, got: ${JSON.stringify(item)}`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (seen.has(item)) {
|
|
232
|
+
throw new IdentityCardError(
|
|
233
|
+
`identity card ${fieldName} has a duplicate entry: ${JSON.stringify(item)}`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
seen.add(item);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Serialize a validated UNSIGNED identity-card payload to its canonical, byte-deterministic bytes: a FIXED
|
|
242
|
+
* key order, NO insignificant whitespace, a single trailing newline. claims/nonClaims are emitted in the
|
|
243
|
+
* caller's order (their order is part of the card the vendor publishes — NOT order-independent like the
|
|
244
|
+
* license entitlement table). This is the EXACT byte sequence the envelope signs over and verifyIdentityCard
|
|
245
|
+
* re-derives the signer from, so two logically-identical cards sign identically.
|
|
246
|
+
* @param {object} payload a validated identity-card payload
|
|
247
|
+
* @returns {string} the canonical serialization (newline-terminated)
|
|
248
|
+
*/
|
|
249
|
+
function serializeIdentityCard(payload) {
|
|
250
|
+
validateIdentityCard(payload);
|
|
251
|
+
const canonical = {
|
|
252
|
+
kind: payload.kind,
|
|
253
|
+
schemaVersion: payload.schemaVersion,
|
|
254
|
+
note: payload.note,
|
|
255
|
+
vendorAddress: payload.vendorAddress,
|
|
256
|
+
productLine: payload.productLine,
|
|
257
|
+
claims: payload.claims.slice(),
|
|
258
|
+
nonClaims: payload.nonClaims.slice(),
|
|
259
|
+
publishedAt: payload.publishedAt,
|
|
260
|
+
};
|
|
261
|
+
return JSON.stringify(canonical) + "\n";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Assemble + strictly validate an UNSIGNED identity-card payload from caller fields. PURE. This is the
|
|
266
|
+
* payload `buildIdentityCard` then wraps in the signed envelope. Splitting it out lets a caller hold/inspect
|
|
267
|
+
* the unsigned card before signing (and validates it the SAME way the embedded payload is re-validated on
|
|
268
|
+
* read). A checksummed/mixed-case vendorAddress is normalized to lowercase here (a syntactically invalid
|
|
269
|
+
* address HARD-errors); every other field passes through to validateIdentityCard.
|
|
270
|
+
*
|
|
271
|
+
* @param {object} params { vendorAddress, productLine, claims, nonClaims, publishedAt }
|
|
272
|
+
* @returns {object} a validated, canonicalized identity-card payload
|
|
273
|
+
*/
|
|
274
|
+
function buildIdentityCardPayload(params) {
|
|
275
|
+
if (!isPlainObject(params)) {
|
|
276
|
+
throw new IdentityCardError(
|
|
277
|
+
"buildIdentityCardPayload requires a { vendorAddress, productLine, claims, nonClaims, publishedAt } object"
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
// Normalize a checksummed/mixed-case vendorAddress to canonical lowercase (so a caller may paste an EIP-55
|
|
281
|
+
// address). A syntactically invalid address is a HARD error (named, no surprise).
|
|
282
|
+
let vendorAddress;
|
|
283
|
+
try {
|
|
284
|
+
vendorAddress = getAddress(params.vendorAddress).toLowerCase();
|
|
285
|
+
} catch (_e) {
|
|
286
|
+
throw new IdentityCardError(
|
|
287
|
+
`identity card vendorAddress must be a valid 0x-address, got: ${String(params.vendorAddress)}`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
const payload = {
|
|
291
|
+
kind: IDENTITY_CARD_KIND,
|
|
292
|
+
schemaVersion: IDENTITY_CARD_SCHEMA_VERSION,
|
|
293
|
+
note: IDENTITY_CARD_TRUST_NOTE,
|
|
294
|
+
vendorAddress,
|
|
295
|
+
productLine: params.productLine,
|
|
296
|
+
claims: params.claims,
|
|
297
|
+
nonClaims: params.nonClaims,
|
|
298
|
+
publishedAt: params.publishedAt,
|
|
299
|
+
};
|
|
300
|
+
// validateIdentityCard throws a named error on any malformed/unknown/missing/empty field — never silently
|
|
301
|
+
// accepts. Return the canonicalized payload (re-parsed from serializeIdentityCard) so the in-memory
|
|
302
|
+
// object's field order matches the signed bytes exactly.
|
|
303
|
+
validateIdentityCard(payload);
|
|
304
|
+
return JSON.parse(serializeIdentityCard(payload));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// The SIGNED-attestation framing passed to the GENERIC attestation core. The core does ALL the crypto +
|
|
308
|
+
// the wrap-don't-edit invariant; this supplies ONLY the identity-card framing + the unsigned codec. SAME
|
|
309
|
+
// pattern the seal/license/dataset use — frozen so it can never be mutated mid-flight.
|
|
310
|
+
const SIGNED_IDENTITY_CARD_CFG = Object.freeze({
|
|
311
|
+
kind: SIGNED_IDENTITY_CARD_KIND,
|
|
312
|
+
schemaVersion: SIGNED_IDENTITY_CARD_SCHEMA_VERSION,
|
|
313
|
+
supportedSchemaVersions: SUPPORTED_SIGNED_IDENTITY_CARD_SCHEMA_VERSIONS,
|
|
314
|
+
note: SIGNED_IDENTITY_CARD_TRUST_NOTE,
|
|
315
|
+
label: "signed identity card",
|
|
316
|
+
validateUnsigned: validateIdentityCard,
|
|
317
|
+
serializeUnsigned: serializeIdentityCard,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Mint a SIGNED identity-card container. Builds + validates the unsigned payload (canonicalizing
|
|
322
|
+
* vendorAddress), then routes it + the caller's signer through the SHARED `signAttestation` core, which
|
|
323
|
+
* signs the EXACT canonical bytes (EIP-191 personal_sign) and wraps + validates the container.
|
|
324
|
+
*
|
|
325
|
+
* THE LOAD-BEARING MINT INVARIANT — the key MUST control the address it claims. After signing, the
|
|
326
|
+
* recovered signer is required to EQUAL the embedded `vendorAddress`. A provisioned key that does NOT
|
|
327
|
+
* control the claimed address HARD-errors (IdentityCardError) — the loop refuses to mint a card asserting
|
|
328
|
+
* an identity the signer cannot back. So a built card ALWAYS round-trips to ACCEPT by construction.
|
|
329
|
+
*
|
|
330
|
+
* NO key handling here — the key lives only inside the signer object (an ephemeral Wallet in tests).
|
|
331
|
+
*
|
|
332
|
+
* @param {object} params { vendorAddress, productLine, claims, nonClaims, publishedAt }
|
|
333
|
+
* @param {object} signer an ethers signer-like object: async getAddress() + signMessage()
|
|
334
|
+
* @returns {Promise<object>} the validated signed-identity-card container
|
|
335
|
+
*/
|
|
336
|
+
async function buildIdentityCard(params, signer) {
|
|
337
|
+
const payload = buildIdentityCardPayload(params);
|
|
338
|
+
const container = await coreAttestation.signAttestation(
|
|
339
|
+
{ attestation: payload, signer },
|
|
340
|
+
SIGNED_IDENTITY_CARD_CFG
|
|
341
|
+
);
|
|
342
|
+
// Enforce the mint invariant: recover the signer from the just-signed bytes and require it to EQUAL the
|
|
343
|
+
// embedded vendorAddress. signAttestation already pinned the container's CLAIMED signer to the signer's
|
|
344
|
+
// own address, so this catches the genuine "minting a card for an address this key does not control"
|
|
345
|
+
// case — never a silent mint of an unbacked identity.
|
|
346
|
+
const recovered = coreAttestation.recoverSigner(container); // lowercase 0x-address
|
|
347
|
+
if (recovered !== payload.vendorAddress) {
|
|
348
|
+
throw new IdentityCardError(
|
|
349
|
+
"refusing to mint an identity card the signing key does not control: the recovered signer " +
|
|
350
|
+
`(${recovered}) does NOT equal the card's vendorAddress (${payload.vendorAddress}). Sign with the ` +
|
|
351
|
+
"key that controls vendorAddress, or set vendorAddress to the signing key's address."
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
return container;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Strictly validate a parsed SIGNED identity-card container — thin wrapper over the shared core. */
|
|
358
|
+
function validateSignedIdentityCard(obj) {
|
|
359
|
+
return coreAttestation.validateSignedAttestation(obj, SIGNED_IDENTITY_CARD_CFG);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Serialize a SIGNED identity-card container to its canonical bytes — thin wrapper over the shared core. */
|
|
363
|
+
function serializeSignedIdentityCard(container) {
|
|
364
|
+
return coreAttestation.serializeSignedAttestation(container, SIGNED_IDENTITY_CARD_CFG);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Read + strictly validate a SIGNED identity-card container (JSON string or object). A parse error is an
|
|
369
|
+
* IdentityCardError (never a raw SyntaxError); a malformed/corrupt container is rejected by the shared
|
|
370
|
+
* validator, never half-accepted.
|
|
371
|
+
* @param {string|object} input
|
|
372
|
+
* @returns {object} the validated container
|
|
373
|
+
*/
|
|
374
|
+
function readIdentityCard(input) {
|
|
375
|
+
let obj;
|
|
376
|
+
if (typeof input === "string") {
|
|
377
|
+
try {
|
|
378
|
+
obj = JSON.parse(input);
|
|
379
|
+
} catch (e) {
|
|
380
|
+
throw new IdentityCardError(`identity card container is not valid JSON: ${e.message}`);
|
|
381
|
+
}
|
|
382
|
+
} else if (isPlainObject(input)) {
|
|
383
|
+
obj = input;
|
|
384
|
+
} else {
|
|
385
|
+
throw new IdentityCardError("readIdentityCard requires a JSON string or a signed-identity-card container object");
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
coreAttestation.validateSignedAttestation(obj, SIGNED_IDENTITY_CARD_CFG);
|
|
389
|
+
} catch (e) {
|
|
390
|
+
throw new IdentityCardError(e.message);
|
|
391
|
+
}
|
|
392
|
+
return obj;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Verify (purely, OFFLINE) a SIGNED identity-card container — the STRICT, PURE verify path. It recovers the
|
|
397
|
+
* signer from the embedded canonical card bytes + signature and:
|
|
398
|
+
* (1) confirms it equals the container's CLAIMED `signer` (signatureMatchesSigner — ALWAYS run);
|
|
399
|
+
* (2) confirms it equals the card's OWN embedded `vendorAddress` (vendorAddressMatchesSigner — ALWAYS
|
|
400
|
+
* run; this is the load-bearing "the key controls the address it claims" check);
|
|
401
|
+
* (3) OPTIONALLY pins it to an EXPECTED signer (`expectedSigner` / the CLI `--signer` flag —
|
|
402
|
+
* signerMatchesExpected, run ONLY when present).
|
|
403
|
+
* The verdict is ACCEPTED only when EVERY requested check passes; a forged/mismatched/tampered card is a
|
|
404
|
+
* clean REJECTED — NEVER a silent pass, NEVER a thrown error for an ordinary rejection.
|
|
405
|
+
*
|
|
406
|
+
* It is OFFLINE / key-free / network-free / I/O-free: it recovers a PUBLIC address from a signature, holds
|
|
407
|
+
* no private key, contacts nothing, writes nothing, and mutates the container NOT at all. The returned
|
|
408
|
+
* shape EXTENDS the FAMILY verdict shape (the byte-for-byte fields `verifySignedAttestation` returns,
|
|
409
|
+
* including `checks`/`failedChecks`/`recoveredSigner`/`claimedSigner` so a future indexer/UI depends on
|
|
410
|
+
* ONE stable shape) with the identity-specific `vendorAddress` + `checks.vendorAddressMatchesSigner`.
|
|
411
|
+
*
|
|
412
|
+
* STRUCTURAL SAFETY: the container is validated FIRST (validateSignedIdentityCard); a structurally invalid
|
|
413
|
+
* container HARD-errors (IdentityCardError) before any recovery, so an ordinary REJECTED verdict only ever
|
|
414
|
+
* describes a SOUND card whose signature simply doesn't back its claims.
|
|
415
|
+
*
|
|
416
|
+
* @param {object} params
|
|
417
|
+
* @param {object} params.container a signed-identity-card container (from buildIdentityCard/readIdentityCard)
|
|
418
|
+
* @param {string} [params.expectedSigner] OPTIONAL expected signer 0x-address (--signer); checked when present
|
|
419
|
+
* @returns {{
|
|
420
|
+
* verdict: "ACCEPTED"|"REJECTED",
|
|
421
|
+
* accepted: boolean,
|
|
422
|
+
* recoveredSigner: string,
|
|
423
|
+
* claimedSigner: string,
|
|
424
|
+
* vendorAddress: string,
|
|
425
|
+
* scheme: string,
|
|
426
|
+
* checks: {
|
|
427
|
+
* signatureMatchesSigner: boolean,
|
|
428
|
+
* vendorAddressMatchesSigner: boolean,
|
|
429
|
+
* signerMatchesExpected: boolean|null,
|
|
430
|
+
* },
|
|
431
|
+
* expectedSigner: string|null,
|
|
432
|
+
* failedChecks: string[],
|
|
433
|
+
* }}
|
|
434
|
+
*/
|
|
435
|
+
function verifyIdentityCard(params) {
|
|
436
|
+
if (!isPlainObject(params)) {
|
|
437
|
+
throw new IdentityCardError("verifyIdentityCard requires { container, [expectedSigner] }");
|
|
438
|
+
}
|
|
439
|
+
// Validate the container FIRST (and re-validate the embedded card) so an ordinary REJECTED verdict only
|
|
440
|
+
// ever describes a STRUCTURALLY SOUND card. A corrupt/foreign container is a HARD error, never a verdict.
|
|
441
|
+
const container = validateSignedIdentityCard(params.container);
|
|
442
|
+
const vendorAddress = JSON.parse(container.attestation).vendorAddress; // lowercase 0x (validated above)
|
|
443
|
+
|
|
444
|
+
// Route the signature recovery + the OPTIONAL expected-signer pin through the SHARED generic core (the
|
|
445
|
+
// SAME path the seal/license use). We do NOT pass expectedCanonical — the identity-specific binding is
|
|
446
|
+
// the vendorAddress check below, computed from the embedded card, not from a caller's directory.
|
|
447
|
+
const att = coreAttestation.verifySignedAttestation({
|
|
448
|
+
container,
|
|
449
|
+
expectedSigner: params.expectedSigner,
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// (2) The load-bearing identity check: the RECOVERED signer must equal the card's OWN vendorAddress. We
|
|
453
|
+
// pin against the RECOVERED signer (not the merely-CLAIMED one), so a card that claims a vendorAddress
|
|
454
|
+
// its signature does not back is REJECTED. When the signature is unrecoverable, recoveredSigner is the
|
|
455
|
+
// "(unrecoverable)" sentinel, which can never equal a lowercase 0x-address — so this is false (REJECT).
|
|
456
|
+
const vendorAddressMatchesSigner = att.recoveredSigner === vendorAddress;
|
|
457
|
+
|
|
458
|
+
// The verdict is ACCEPTED only when EVERY requested check passes. signatureMatchesSigner +
|
|
459
|
+
// vendorAddressMatchesSigner are ALWAYS required; signerMatchesExpected only when --signer was given
|
|
460
|
+
// (null = not requested, never fails the gate). We REBUILD failedChecks (the core's list does not know
|
|
461
|
+
// about vendorAddressMatchesSigner) so the verdict, the checks, and failedChecks can never disagree.
|
|
462
|
+
const failedChecks = [];
|
|
463
|
+
if (!att.checks.signatureMatchesSigner) failedChecks.push("signatureMatchesSigner");
|
|
464
|
+
if (!vendorAddressMatchesSigner) failedChecks.push("vendorAddressMatchesSigner");
|
|
465
|
+
if (att.checks.signerMatchesExpected === false) failedChecks.push("signerMatchesExpected");
|
|
466
|
+
const accepted = failedChecks.length === 0;
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
verdict: accepted ? "ACCEPTED" : "REJECTED",
|
|
470
|
+
accepted,
|
|
471
|
+
recoveredSigner: att.recoveredSigner,
|
|
472
|
+
claimedSigner: att.claimedSigner,
|
|
473
|
+
vendorAddress,
|
|
474
|
+
scheme: att.scheme,
|
|
475
|
+
checks: {
|
|
476
|
+
signatureMatchesSigner: att.checks.signatureMatchesSigner,
|
|
477
|
+
vendorAddressMatchesSigner,
|
|
478
|
+
signerMatchesExpected: att.checks.signerMatchesExpected,
|
|
479
|
+
},
|
|
480
|
+
expectedSigner: att.expectedSigner,
|
|
481
|
+
failedChecks,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ===========================================================================
|
|
486
|
+
// THE CLI SURFACE — `vh identity publish` (mint) + `vh identity verify` (check + pin).
|
|
487
|
+
//
|
|
488
|
+
// `vh identity publish` MINTS the signed producer identity card the family's recipients verify. It signs
|
|
489
|
+
// with a HUMAN-provisioned key (EXACTLY ONE of --key-env/--key-file, read-used-discarded via the SHARED
|
|
490
|
+
// loadSigningWallet — the loop NEVER generates/persists/logs a key). It mints ONLY when the provisioned
|
|
491
|
+
// key's address EQUALS the caller's --address (the buildIdentityCard mint invariant), so a card can never
|
|
492
|
+
// assert an identity the key does not control. Default prints the card + writes NOTHING; --out writes to a
|
|
493
|
+
// caller-chosen path (never silently to cwd).
|
|
494
|
+
//
|
|
495
|
+
// `vh identity verify <card>` is the OFFLINE / key-free / network-free read path. It LEADS with the trust
|
|
496
|
+
// line, prints the claims/non-claims + per-check PASS/FAIL, and OPTIONALLY pins --signer. ACCEPT/REJECT/
|
|
497
|
+
// usage exits map 0/3/2/1 (mirrors the family's read commands).
|
|
498
|
+
//
|
|
499
|
+
// The CLI is a THIN I/O shell over the PURE core above: all crypto/validation lives in buildIdentityCard/
|
|
500
|
+
// verifyIdentityCard; this layer only parses argv, reads/writes files, and renders.
|
|
501
|
+
// ===========================================================================
|
|
502
|
+
|
|
503
|
+
// Exit contract shared with the rest of the family: 0 ok/ACCEPTED / 1 IO / 2 usage / 3 gate-fail
|
|
504
|
+
// (verify REJECTED). Mirrors cli/evidence.js's EXIT so every gate reads the same.
|
|
505
|
+
const EXIT = Object.freeze({ OK: 0, IO: 1, USAGE: 2, FAIL: 3 });
|
|
506
|
+
|
|
507
|
+
// Parse `identity publish` argv. --claim/--non-claim are REPEATABLE (each occurrence appends, preserving
|
|
508
|
+
// order — the order is part of the published card). EXACTLY-ONE-of key sources is enforced downstream by
|
|
509
|
+
// loadSigningWallet (so neither/both error key-free); the parser only collects flags. A flag without its
|
|
510
|
+
// value, or an unknown flag, is a usage error (e.usage=true) — a typo never silently changes the card.
|
|
511
|
+
function parsePublishArgs(argv) {
|
|
512
|
+
const opts = {
|
|
513
|
+
address: undefined,
|
|
514
|
+
productLine: undefined,
|
|
515
|
+
claims: [],
|
|
516
|
+
nonClaims: [],
|
|
517
|
+
publishedAt: undefined,
|
|
518
|
+
keyEnv: undefined,
|
|
519
|
+
keyFile: undefined,
|
|
520
|
+
out: undefined,
|
|
521
|
+
json: false,
|
|
522
|
+
};
|
|
523
|
+
for (let i = 0; i < argv.length; i++) {
|
|
524
|
+
const a = argv[i];
|
|
525
|
+
const need = (flag) => {
|
|
526
|
+
const v = argv[++i];
|
|
527
|
+
if (v === undefined) {
|
|
528
|
+
const e = new Error(`${flag} requires a value`);
|
|
529
|
+
e.usage = true;
|
|
530
|
+
throw e;
|
|
531
|
+
}
|
|
532
|
+
return v;
|
|
533
|
+
};
|
|
534
|
+
switch (a) {
|
|
535
|
+
case "--address":
|
|
536
|
+
opts.address = need("--address");
|
|
537
|
+
break;
|
|
538
|
+
case "--product-line":
|
|
539
|
+
opts.productLine = need("--product-line");
|
|
540
|
+
break;
|
|
541
|
+
case "--claim":
|
|
542
|
+
opts.claims.push(need("--claim"));
|
|
543
|
+
break;
|
|
544
|
+
case "--non-claim":
|
|
545
|
+
opts.nonClaims.push(need("--non-claim"));
|
|
546
|
+
break;
|
|
547
|
+
case "--published-at":
|
|
548
|
+
opts.publishedAt = need("--published-at");
|
|
549
|
+
break;
|
|
550
|
+
case "--key-env":
|
|
551
|
+
opts.keyEnv = need("--key-env");
|
|
552
|
+
break;
|
|
553
|
+
case "--key-file":
|
|
554
|
+
opts.keyFile = need("--key-file");
|
|
555
|
+
break;
|
|
556
|
+
case "--out":
|
|
557
|
+
opts.out = need("--out");
|
|
558
|
+
break;
|
|
559
|
+
case "--json":
|
|
560
|
+
opts.json = true;
|
|
561
|
+
break;
|
|
562
|
+
default: {
|
|
563
|
+
const e = new Error(`unknown flag: ${a} (identity publish takes no positional arguments)`);
|
|
564
|
+
e.usage = true;
|
|
565
|
+
throw e;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return opts;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Real "now" as a canonical ISO-8601 UTC instant — the publish default clock, isolated + injectable
|
|
573
|
+
// (io.nowISO) so the command stays deterministic under test. publishedAt defaults to this when omitted.
|
|
574
|
+
function nowISO() {
|
|
575
|
+
return new Date().toISOString();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* `vh identity publish` — MINT a signed identity card. PURE core + the only I/O being the OPTIONAL --out
|
|
580
|
+
* write (the signing is offline/key-free in the sense that the loop holds no key; the human's key lives
|
|
581
|
+
* ONLY inside the in-process Wallet loadSigningWallet builds and is discarded).
|
|
582
|
+
*
|
|
583
|
+
* The mint is REFUSED (HARD error, BEFORE any write) when the provisioned key's address != --address — so
|
|
584
|
+
* a card can never assert an identity the key does not control. The output LEADS with the trust line.
|
|
585
|
+
*
|
|
586
|
+
* Exit: 0 ok / 2 usage (missing/invalid field, key-source error, mint-control mismatch) / 1 IO (--out write).
|
|
587
|
+
*/
|
|
588
|
+
async function runIdentityPublish(opts, io = {}) {
|
|
589
|
+
const write = io.write || ((s) => process.stdout.write(s));
|
|
590
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
591
|
+
|
|
592
|
+
// Required order fields up front (a missing one is a clean usage error, never a confusing core throw).
|
|
593
|
+
if (opts.address == null) {
|
|
594
|
+
writeErr("error: `vh identity publish` requires --address <0xaddr> (the vendor address the card binds)\n");
|
|
595
|
+
return EXIT.USAGE;
|
|
596
|
+
}
|
|
597
|
+
if (opts.productLine == null) {
|
|
598
|
+
writeErr(
|
|
599
|
+
`error: \`vh identity publish\` requires --product-line <line> (one of ${JSON.stringify(PRODUCT_LINE_SET)})\n`
|
|
600
|
+
);
|
|
601
|
+
return EXIT.USAGE;
|
|
602
|
+
}
|
|
603
|
+
if (opts.claims.length === 0) {
|
|
604
|
+
writeErr("error: `vh identity publish` requires at least one --claim <text> (what the vendor attests)\n");
|
|
605
|
+
return EXIT.USAGE;
|
|
606
|
+
}
|
|
607
|
+
if (opts.nonClaims.length === 0) {
|
|
608
|
+
writeErr(
|
|
609
|
+
"error: `vh identity publish` requires at least one --non-claim <text> " +
|
|
610
|
+
"(the honest boundary: what the vendor explicitly does NOT attest)\n"
|
|
611
|
+
);
|
|
612
|
+
return EXIT.USAGE;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Validate the --address SHAPE up front so a malformed address is a usage error (2), never a runtime
|
|
616
|
+
// throw mid-mint. buildIdentityCardPayload also normalizes/validates, but failing fast here gives the
|
|
617
|
+
// clean exit-2 the contract promises. (isAddress accepts checksummed/lowercase 0x-addresses.)
|
|
618
|
+
if (!isAddress(opts.address)) {
|
|
619
|
+
writeErr(`error: invalid --address: ${opts.address} (expected a 20-byte 0x-hex address)\n`);
|
|
620
|
+
return EXIT.USAGE;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Resolve the HUMAN-supplied key into an in-process Wallet FIRST — neither/both sources, a missing env
|
|
624
|
+
// var, an unreadable file, or a malformed/zero key HARD-ERRORS here with a KEY-FREE message (the SAME
|
|
625
|
+
// core + posture as `vh evidence seal --sign`). The loop NEVER holds/generates/persists/logs a key.
|
|
626
|
+
let wallet;
|
|
627
|
+
try {
|
|
628
|
+
({ wallet } = coreAttestation.loadSigningWallet({ keyEnv: opts.keyEnv, keyFile: opts.keyFile }));
|
|
629
|
+
} catch (e) {
|
|
630
|
+
writeErr(`error: ${e.message}\n`);
|
|
631
|
+
return EXIT.USAGE;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// publishedAt defaults to the injectable clock (a real ISO instant at runtime; a pinned one in tests).
|
|
635
|
+
const publishedAt = opts.publishedAt != null ? opts.publishedAt : (io.nowISO || nowISO)();
|
|
636
|
+
|
|
637
|
+
// Build + sign + enforce the mint invariant in the PURE core. A malformed field (out-of-set productLine,
|
|
638
|
+
// empty/duplicate claim, non-canonical date) OR the key NOT controlling --address throws IdentityCardError
|
|
639
|
+
// — a usage error (2), BEFORE any --out write. The message never includes the key.
|
|
640
|
+
let container;
|
|
641
|
+
try {
|
|
642
|
+
container = await buildIdentityCard(
|
|
643
|
+
{
|
|
644
|
+
vendorAddress: opts.address,
|
|
645
|
+
productLine: opts.productLine,
|
|
646
|
+
claims: opts.claims,
|
|
647
|
+
nonClaims: opts.nonClaims,
|
|
648
|
+
publishedAt,
|
|
649
|
+
},
|
|
650
|
+
wallet
|
|
651
|
+
);
|
|
652
|
+
} catch (e) {
|
|
653
|
+
writeErr(`error: ${e.message}\n`);
|
|
654
|
+
return EXIT.USAGE;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const canonical = serializeSignedIdentityCard(container);
|
|
658
|
+
const payload = JSON.parse(container.attestation);
|
|
659
|
+
// The PUBLIC vendor address — recovered from the signature, never the key. By the mint invariant it
|
|
660
|
+
// equals payload.vendorAddress; we recover it to PROVE that (and to print "signed by" from the signature).
|
|
661
|
+
const signedBy = coreAttestation.recoverSigner(container);
|
|
662
|
+
|
|
663
|
+
// Write to --out (caller-chosen path; NEVER cwd) or print to stdout (writes nothing).
|
|
664
|
+
let outAbs = null;
|
|
665
|
+
if (opts.out) {
|
|
666
|
+
outAbs = path.resolve(opts.out);
|
|
667
|
+
try {
|
|
668
|
+
fs.writeFileSync(outAbs, canonical);
|
|
669
|
+
} catch (e) {
|
|
670
|
+
writeErr(`error: cannot write --out file ${opts.out}: ${e.message}\n`);
|
|
671
|
+
return EXIT.IO;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (opts.json) {
|
|
676
|
+
// ONLY public fields: the vendor ADDRESS (recovered), the card summary, the path — NEVER the key. With
|
|
677
|
+
// no --out the canonical bytes ride in `container` so --json never drops the artifact (family parity).
|
|
678
|
+
write(
|
|
679
|
+
JSON.stringify(
|
|
680
|
+
{
|
|
681
|
+
published: true,
|
|
682
|
+
note: IDENTITY_CARD_TRUST_NOTE,
|
|
683
|
+
kind: SIGNED_IDENTITY_CARD_KIND,
|
|
684
|
+
vendorAddress: payload.vendorAddress,
|
|
685
|
+
signer: signedBy,
|
|
686
|
+
productLine: payload.productLine,
|
|
687
|
+
claims: payload.claims,
|
|
688
|
+
nonClaims: payload.nonClaims,
|
|
689
|
+
publishedAt: payload.publishedAt,
|
|
690
|
+
out: outAbs,
|
|
691
|
+
container: outAbs ? null : canonical,
|
|
692
|
+
},
|
|
693
|
+
null,
|
|
694
|
+
2
|
|
695
|
+
) + "\n"
|
|
696
|
+
);
|
|
697
|
+
} else {
|
|
698
|
+
write(IDENTITY_CARD_TRUST_NOTE + "\n\n");
|
|
699
|
+
write(`published a signed identity card for ${payload.vendorAddress} (signed by ${signedBy})\n`);
|
|
700
|
+
write(` productLine: ${payload.productLine}\n`);
|
|
701
|
+
write(` publishedAt: ${payload.publishedAt}\n`);
|
|
702
|
+
write(` claims (${payload.claims.length}):\n`);
|
|
703
|
+
for (const c of payload.claims) write(` + ${c}\n`);
|
|
704
|
+
write(` nonClaims (${payload.nonClaims.length}):\n`);
|
|
705
|
+
for (const c of payload.nonClaims) write(` - ${c}\n`);
|
|
706
|
+
if (outAbs) {
|
|
707
|
+
write(` written: ${outAbs}\n`);
|
|
708
|
+
} else {
|
|
709
|
+
// Default: print the card bytes so a publisher can eyeball/redirect them — still writes nothing.
|
|
710
|
+
write(canonical);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return EXIT.OK;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Parse `identity verify` argv. Takes exactly one positional <card> + OPTIONAL --signer/--json.
|
|
717
|
+
function parseVerifyArgs(argv) {
|
|
718
|
+
const opts = {
|
|
719
|
+
card: undefined,
|
|
720
|
+
signer: undefined,
|
|
721
|
+
revocations: undefined,
|
|
722
|
+
asOf: undefined,
|
|
723
|
+
json: false,
|
|
724
|
+
_positionals: [],
|
|
725
|
+
};
|
|
726
|
+
for (let i = 0; i < argv.length; i++) {
|
|
727
|
+
const a = argv[i];
|
|
728
|
+
const need = (flag) => {
|
|
729
|
+
const v = argv[++i];
|
|
730
|
+
if (v === undefined) {
|
|
731
|
+
const e = new Error(`${flag} requires a value`);
|
|
732
|
+
e.usage = true;
|
|
733
|
+
throw e;
|
|
734
|
+
}
|
|
735
|
+
return v;
|
|
736
|
+
};
|
|
737
|
+
switch (a) {
|
|
738
|
+
case "--signer":
|
|
739
|
+
opts.signer = need("--signer");
|
|
740
|
+
break;
|
|
741
|
+
case "--revocations":
|
|
742
|
+
opts.revocations = need("--revocations");
|
|
743
|
+
break;
|
|
744
|
+
case "--as-of":
|
|
745
|
+
opts.asOf = need("--as-of");
|
|
746
|
+
break;
|
|
747
|
+
case "--json":
|
|
748
|
+
opts.json = true;
|
|
749
|
+
break;
|
|
750
|
+
default:
|
|
751
|
+
if (a && a.startsWith("--")) {
|
|
752
|
+
const e = new Error(`unknown flag: ${a}`);
|
|
753
|
+
e.usage = true;
|
|
754
|
+
throw e;
|
|
755
|
+
}
|
|
756
|
+
opts._positionals.push(a);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
if (opts._positionals.length > 1) {
|
|
760
|
+
const e = new Error(
|
|
761
|
+
`unexpected extra argument: ${opts._positionals[1]} (identity verify takes exactly one <card>)`
|
|
762
|
+
);
|
|
763
|
+
e.usage = true;
|
|
764
|
+
throw e;
|
|
765
|
+
}
|
|
766
|
+
opts.card = opts._positionals[0];
|
|
767
|
+
return opts;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// The standing trust line the verify path LEADS with — reuses the SIGNED-card note verbatim (so the human
|
|
771
|
+
// + JSON caveats can NEVER drift). It is the load-bearing honesty of the read: an ACCEPT proves IDENTITY +
|
|
772
|
+
// the claim SET, NOT any specific packet, NOT a timestamp (P-3), NOT a legal opinion.
|
|
773
|
+
const VERIFY_TRUST_NOTE = SIGNED_IDENTITY_CARD_TRUST_NOTE;
|
|
774
|
+
|
|
775
|
+
// Render the human verify report. PURE. LEADS with the trust line, prints the verdict, the recovered/
|
|
776
|
+
// claimed/vendor address, the per-check PASS/FAIL (Check 1 + the vendorAddress identity check ALWAYS; the
|
|
777
|
+
// --signer pin only when requested), then the published CLAIMS + NON-CLAIMS. A REJECTED verdict NAMES the
|
|
778
|
+
// failing check(s). The claims/non-claims are rendered FROM THE RECOVERED, validated card (never trusted
|
|
779
|
+
// blindly: a REJECTED card's claims are shown but the verdict makes clear they are NOT backed).
|
|
780
|
+
function renderVerify(r, card, ctx) {
|
|
781
|
+
const L = [];
|
|
782
|
+
// TRUST FIRST.
|
|
783
|
+
L.push("TRUST: " + VERIFY_TRUST_NOTE);
|
|
784
|
+
L.push("");
|
|
785
|
+
L.push(`# vh identity verify — ${ctx.card}`);
|
|
786
|
+
L.push(`identity: ${r.verdict}`);
|
|
787
|
+
L.push(`scheme: ${r.scheme}`);
|
|
788
|
+
L.push(`vendorAddress: ${r.vendorAddress} (the address the card BINDS to the signing key)`);
|
|
789
|
+
L.push(`recovered signer: ${r.recoveredSigner} (from the embedded canonical card bytes + signature)`);
|
|
790
|
+
L.push(`claimed signer: ${r.claimedSigner} (the container's \`signer\` field)`);
|
|
791
|
+
L.push(`productLine: ${card.productLine}`);
|
|
792
|
+
L.push(`publishedAt: ${card.publishedAt}`);
|
|
793
|
+
// Check 1 (ALWAYS): the signature recovers to the claimed signer.
|
|
794
|
+
L.push(` [${r.checks.signatureMatchesSigner ? "PASS" : "FAIL"}] signature recovers to the claimed signer`);
|
|
795
|
+
// The load-bearing identity check (ALWAYS): the recovered signer IS the card's own vendorAddress.
|
|
796
|
+
L.push(
|
|
797
|
+
` [${r.checks.vendorAddressMatchesSigner ? "PASS" : "FAIL"}] the recovered signer IS the card's ` +
|
|
798
|
+
"vendorAddress (the key controls the address it claims)"
|
|
799
|
+
);
|
|
800
|
+
// Check 3 (only under --signer): the recovered signer equals the expected, out-of-band signer.
|
|
801
|
+
if (r.checks.signerMatchesExpected === null) {
|
|
802
|
+
L.push(" [skip] expected-signer pin: not requested (pass --signer <0xaddr> to pin the signer)");
|
|
803
|
+
} else {
|
|
804
|
+
L.push(
|
|
805
|
+
` [${r.checks.signerMatchesExpected ? "PASS" : "FAIL"}] recovered signer matches the expected ` +
|
|
806
|
+
`signer (${r.expectedSigner})`
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
// The published CLAIMS + NON-CLAIMS — the WHOLE point of the card (what the vendor attests + does NOT).
|
|
810
|
+
L.push(`claims (${card.claims.length}) — what this vendor attests:`);
|
|
811
|
+
for (const c of card.claims) L.push(` + ${c}`);
|
|
812
|
+
L.push(`nonClaims (${card.nonClaims.length}) — what this vendor explicitly does NOT attest:`);
|
|
813
|
+
for (const c of card.nonClaims) L.push(` - ${c}`);
|
|
814
|
+
if (r.accepted) {
|
|
815
|
+
L.push("ACCEPTED: every requested check passed — this card's identity + claim set are backed by the signer.");
|
|
816
|
+
} else {
|
|
817
|
+
L.push(`REJECTED: failed check(s): ${r.failedChecks.join(", ")}.`);
|
|
818
|
+
if (r.failedChecks.includes("signatureMatchesSigner")) {
|
|
819
|
+
L.push(
|
|
820
|
+
" forged/tampered: the signature does NOT recover to the claimed `signer` — the card's claims are UNBACKED."
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
if (r.failedChecks.includes("vendorAddressMatchesSigner")) {
|
|
824
|
+
L.push(
|
|
825
|
+
" vendor-mismatch: the recovered signer is NOT the card's vendorAddress — the card asserts an identity"
|
|
826
|
+
);
|
|
827
|
+
L.push(" the signing key does NOT control. Its claims are NOT backed by the address it names.");
|
|
828
|
+
}
|
|
829
|
+
if (r.failedChecks.includes("signerMatchesExpected")) {
|
|
830
|
+
L.push(
|
|
831
|
+
" pin-mismatch: the signature is genuine but the signer is NOT the address you pinned with --signer."
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
L.push("");
|
|
836
|
+
return L.join("\n");
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* `vh identity verify <card> [--signer <0xaddr>] [--json]` — OFFLINE / key-free / network-free. RECOVERS
|
|
841
|
+
* the signer from a signed identity card and confirms (1) the signature backs the claimed signer, (2) the
|
|
842
|
+
* recovered signer IS the card's own vendorAddress (the load-bearing identity check), and OPTIONALLY (3)
|
|
843
|
+
* pins it to an expected --signer. LEADS with the trust line; prints the claims/non-claims + per-check
|
|
844
|
+
* PASS/FAIL. A forged/tampered/wrong-key card, or a wrong --signer, is a clean REJECTED — NEVER a silent
|
|
845
|
+
* pass. Writes NOTHING. Exit: 0 ACCEPTED / 3 REJECTED / 2 usage / 1 IO.
|
|
846
|
+
*/
|
|
847
|
+
// Shared up-front shape validation for the OPTIONAL recipient-side trust-decision flags (--revocations /
|
|
848
|
+
// --as-of, T-51.2). Returns null when fine, else a usage-error message. A malformed --as-of is a usage error
|
|
849
|
+
// (never a runtime throw mid-verify); --as-of without --revocations is a usage error. Mirrors the trust-asof
|
|
850
|
+
// core's canonical-instant grammar.
|
|
851
|
+
function validateAsOfFlags(opts) {
|
|
852
|
+
if (opts.asOf !== undefined && !opts.revocations) {
|
|
853
|
+
return "--as-of requires --revocations (it pins the instant the revocation decision is made AS OF)";
|
|
854
|
+
}
|
|
855
|
+
if (opts.asOf !== undefined) {
|
|
856
|
+
const re = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
|
|
857
|
+
const ms = Date.parse(opts.asOf);
|
|
858
|
+
if (
|
|
859
|
+
typeof opts.asOf !== "string" ||
|
|
860
|
+
!re.test(opts.asOf) ||
|
|
861
|
+
Number.isNaN(ms) ||
|
|
862
|
+
new Date(ms).toISOString() !== opts.asOf
|
|
863
|
+
) {
|
|
864
|
+
return `invalid --as-of: ${opts.asOf} (expected a canonical ISO-8601 UTC instant, e.g. 2026-06-01T00:00:00.000Z)`;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function runIdentityVerify(opts, io = {}) {
|
|
871
|
+
const write = io.write || ((s) => process.stdout.write(s));
|
|
872
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
873
|
+
|
|
874
|
+
if (!opts.card) {
|
|
875
|
+
writeErr("error: `vh identity verify` requires a <card> (a signed identity-card file path)\n");
|
|
876
|
+
return EXIT.USAGE;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Validate the --signer SHAPE up front (when given) so a malformed pin is a usage error (2), never a
|
|
880
|
+
// runtime throw inside verifyIdentityCard (which normalizes via getAddress and would throw). OFFLINE.
|
|
881
|
+
if (opts.signer !== undefined && opts.signer !== null) {
|
|
882
|
+
if (!isAddress(opts.signer)) {
|
|
883
|
+
writeErr(`error: invalid --signer address: ${opts.signer} (expected a 20-byte 0x-hex address)\n`);
|
|
884
|
+
return EXIT.USAGE;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Validate the OPTIONAL trust-decision flags (--revocations/--as-of, T-51.2) SHAPE up front so a malformed
|
|
889
|
+
// --as-of (or --as-of without --revocations) is a usage error (2), never a runtime throw mid-verify.
|
|
890
|
+
{
|
|
891
|
+
const asOfErr = validateAsOfFlags(opts);
|
|
892
|
+
if (asOfErr) {
|
|
893
|
+
writeErr(`error: ${asOfErr}\n`);
|
|
894
|
+
return EXIT.USAGE;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Read + STRICT-validate the container BEFORE any recovery — a malformed/edited/foreign container (or a
|
|
899
|
+
// non-card file) hard-errors (exit 1), never half-accepted. A forged signature is NOT a parse error:
|
|
900
|
+
// readIdentityCard proves the bytes are canonical; the recovery (the verdict) runs below in the PURE core.
|
|
901
|
+
let container;
|
|
902
|
+
try {
|
|
903
|
+
const text = fs.readFileSync(path.resolve(opts.card), "utf8");
|
|
904
|
+
container = readIdentityCard(text);
|
|
905
|
+
} catch (e) {
|
|
906
|
+
writeErr(`error: cannot read signed identity card ${opts.card}: ${e.message}\n`);
|
|
907
|
+
return EXIT.IO;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Run the PURE, OFFLINE verify. No I/O, no key, no network. A structurally-sound-but-forged/mismatched
|
|
911
|
+
// card is a clean REJECTED verdict (not a throw); only a genuinely broken read would throw (caught above).
|
|
912
|
+
let result;
|
|
913
|
+
try {
|
|
914
|
+
result = verifyIdentityCard({ container, expectedSigner: opts.signer });
|
|
915
|
+
} catch (e) {
|
|
916
|
+
writeErr(`error: ${e.message}\n`);
|
|
917
|
+
return EXIT.IO;
|
|
918
|
+
}
|
|
919
|
+
const card = JSON.parse(container.attestation); // the validated embedded card (claims/nonClaims/etc.)
|
|
920
|
+
|
|
921
|
+
// OPTIONAL recipient-side TRUST-DECISION-AS-OF (EPIC-51 / T-51.2). Runs ONLY under --revocations — with no
|
|
922
|
+
// flag the result is byte-identical to the pre-EPIC baseline. A card whose vendor key was revoked-before-
|
|
923
|
+
// as-of downgrades an otherwise-ACCEPTED card to REVOKED (exit 3); a later revocation is informational; a
|
|
924
|
+
// forged one is ignored with a warning. OFFLINE / key-free on the read side. The revocations file is the
|
|
925
|
+
// ONLY new I/O. The subject is the card's RECOVERED signer (== its vendorAddress on an ACCEPTED card).
|
|
926
|
+
let defaulted = false;
|
|
927
|
+
if (opts.revocations) {
|
|
928
|
+
try {
|
|
929
|
+
const applied = coreTrustAsOf.loadAndApply({
|
|
930
|
+
result,
|
|
931
|
+
revocationsPath: opts.revocations,
|
|
932
|
+
asOf: opts.asOf,
|
|
933
|
+
nowISO: io.nowISO || new Date().toISOString(),
|
|
934
|
+
readFile: (p) => fs.readFileSync(path.resolve(p), "utf8"),
|
|
935
|
+
});
|
|
936
|
+
result = applied.result;
|
|
937
|
+
defaulted = applied.defaulted;
|
|
938
|
+
} catch (e) {
|
|
939
|
+
writeErr(`error: cannot evaluate --revocations ${opts.revocations}: ${e.message}\n`);
|
|
940
|
+
return EXIT.IO;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (opts.json) {
|
|
945
|
+
write(
|
|
946
|
+
JSON.stringify(
|
|
947
|
+
{
|
|
948
|
+
...result,
|
|
949
|
+
// The published claim set the verdict is about (so a machine reader gets identity + claims in one).
|
|
950
|
+
productLine: card.productLine,
|
|
951
|
+
claims: card.claims,
|
|
952
|
+
nonClaims: card.nonClaims,
|
|
953
|
+
publishedAt: card.publishedAt,
|
|
954
|
+
card: opts.card,
|
|
955
|
+
note: VERIFY_TRUST_NOTE,
|
|
956
|
+
},
|
|
957
|
+
null,
|
|
958
|
+
2
|
|
959
|
+
) + "\n"
|
|
960
|
+
);
|
|
961
|
+
} else {
|
|
962
|
+
let out = renderVerify(result, card, { card: opts.card });
|
|
963
|
+
if (result.trustAsOf) {
|
|
964
|
+
out += coreTrustAsOf.renderTrustAsOf(result.trustAsOf, { defaulted }).join("\n") + "\n";
|
|
965
|
+
}
|
|
966
|
+
write(out);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Exit non-zero on REJECTED/REVOKED so a buyer's CI can gate (0 ACCEPTED / 3 not-OK — the family's read contract).
|
|
970
|
+
return result.accepted ? EXIT.OK : EXIT.FAIL;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function identityUsage() {
|
|
974
|
+
return [
|
|
975
|
+
"vh identity — publish + verify a producer IDENTITY CARD (bind a vendor address to a bounded claim set)",
|
|
976
|
+
"",
|
|
977
|
+
"Usage:",
|
|
978
|
+
" vh identity publish --address <0xaddr> --product-line <line> --claim <text> [--claim ...]",
|
|
979
|
+
" --non-claim <text> [--non-claim ...] [--published-at <ISO>]",
|
|
980
|
+
" (--key-env <VAR> | --key-file <path>) [--out <p>] [--json]",
|
|
981
|
+
" vh identity verify <card> [--signer <0xaddr>] [--revocations <f> --as-of <ISO>] [--json]",
|
|
982
|
+
"",
|
|
983
|
+
"publish MINTS a signed card binding --address to the --claim set it attests + the --non-claim set it",
|
|
984
|
+
" explicitly does NOT. It signs with a HUMAN-provisioned key (EXACTLY ONE of --key-env/--key-file,",
|
|
985
|
+
" read-used-discarded; the loop sets/holds NO key) and MINTS ONLY when that key's address EQUALS",
|
|
986
|
+
" --address (else it hard-errors BEFORE writing). --product-line is one of " +
|
|
987
|
+
JSON.stringify(PRODUCT_LINE_SET) + ".",
|
|
988
|
+
" Default prints the card + writes NOTHING; --out writes to a caller-chosen path (never cwd).",
|
|
989
|
+
" Exit: 0 ok / 2 usage (missing/invalid field, key-source error, key does not control --address) / 1 IO.",
|
|
990
|
+
"verify is OFFLINE/key-free/network-free: it RECOVERS the signer, confirms the signature backs it AND that",
|
|
991
|
+
" the recovered signer IS the card's vendorAddress, OPTIONALLY pins --signer, OPTIONALLY checks the vendor",
|
|
992
|
+
" key was not REVOKED as of --as-of (default now) via --revocations, and prints the claims/non-claims +",
|
|
993
|
+
" per-check PASS/FAIL. A forged/tampered/wrong-key card, a wrong --signer, or a key revoked-before-as-of is",
|
|
994
|
+
" a clean REJECTED/REVOKED — never a silent pass. Exit: 0 ACCEPTED / 3 REJECTED|REVOKED / 2 usage / 1 IO.",
|
|
995
|
+
"",
|
|
996
|
+
"A card proves IDENTITY + the claim SET only: NOT any specific sealed packet (each carries its own proof),",
|
|
997
|
+
"NOT a trusted TIMESTAMP (P-3), and NOT a legal opinion.",
|
|
998
|
+
"",
|
|
999
|
+
].join("\n");
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* CLI dispatch: `vh identity <publish|verify> ...`. An UNKNOWN subcommand is a USAGE error (2) — the loop
|
|
1004
|
+
* never silently accepts a typo'd subcommand. `-h`/`--help`/`help`/no-subcommand prints usage.
|
|
1005
|
+
*/
|
|
1006
|
+
async function cmdIdentity(argv, io = {}) {
|
|
1007
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
1008
|
+
const [sub, ...rest] = argv;
|
|
1009
|
+
if (sub === "publish") {
|
|
1010
|
+
let opts;
|
|
1011
|
+
try {
|
|
1012
|
+
opts = parsePublishArgs(rest);
|
|
1013
|
+
} catch (e) {
|
|
1014
|
+
writeErr(`error: ${e.message}\n`);
|
|
1015
|
+
return EXIT.USAGE;
|
|
1016
|
+
}
|
|
1017
|
+
return runIdentityPublish(opts, io);
|
|
1018
|
+
}
|
|
1019
|
+
if (sub === "verify") {
|
|
1020
|
+
let opts;
|
|
1021
|
+
try {
|
|
1022
|
+
opts = parseVerifyArgs(rest);
|
|
1023
|
+
} catch (e) {
|
|
1024
|
+
writeErr(`error: ${e.message}\n`);
|
|
1025
|
+
return EXIT.USAGE;
|
|
1026
|
+
}
|
|
1027
|
+
return runIdentityVerify(opts, io);
|
|
1028
|
+
}
|
|
1029
|
+
if (sub === undefined || sub === "-h" || sub === "--help" || sub === "help") {
|
|
1030
|
+
io.write ? io.write(identityUsage()) : process.stdout.write(identityUsage());
|
|
1031
|
+
return sub === undefined ? EXIT.USAGE : EXIT.OK;
|
|
1032
|
+
}
|
|
1033
|
+
writeErr(`error: unknown identity subcommand: ${sub} (expected: publish, verify)\n`);
|
|
1034
|
+
return EXIT.USAGE;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
module.exports = {
|
|
1038
|
+
// kinds + closed sets
|
|
1039
|
+
IDENTITY_CARD_KIND,
|
|
1040
|
+
IDENTITY_CARD_SCHEMA_VERSION,
|
|
1041
|
+
SUPPORTED_IDENTITY_CARD_SCHEMA_VERSIONS,
|
|
1042
|
+
SIGNED_IDENTITY_CARD_KIND,
|
|
1043
|
+
SIGNED_IDENTITY_CARD_SCHEMA_VERSION,
|
|
1044
|
+
SUPPORTED_SIGNED_IDENTITY_CARD_SCHEMA_VERSIONS,
|
|
1045
|
+
PRODUCT_LINES,
|
|
1046
|
+
PRODUCT_LINE_SET,
|
|
1047
|
+
IDENTITY_CARD_FIELDS,
|
|
1048
|
+
IDENTITY_CARD_TRUST_NOTE,
|
|
1049
|
+
SIGNED_IDENTITY_CARD_TRUST_NOTE,
|
|
1050
|
+
IdentityCardError,
|
|
1051
|
+
// unsigned-payload codec
|
|
1052
|
+
validateIdentityCard,
|
|
1053
|
+
serializeIdentityCard,
|
|
1054
|
+
buildIdentityCardPayload,
|
|
1055
|
+
// signed container
|
|
1056
|
+
buildIdentityCard,
|
|
1057
|
+
validateSignedIdentityCard,
|
|
1058
|
+
serializeSignedIdentityCard,
|
|
1059
|
+
readIdentityCard,
|
|
1060
|
+
verifyIdentityCard,
|
|
1061
|
+
// CLI surface
|
|
1062
|
+
EXIT,
|
|
1063
|
+
nowISO,
|
|
1064
|
+
VERIFY_TRUST_NOTE,
|
|
1065
|
+
parsePublishArgs,
|
|
1066
|
+
parseVerifyArgs,
|
|
1067
|
+
runIdentityPublish,
|
|
1068
|
+
runIdentityVerify,
|
|
1069
|
+
renderVerify,
|
|
1070
|
+
identityUsage,
|
|
1071
|
+
cmdIdentity,
|
|
1072
|
+
};
|