tsp-verify 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.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * verify_license() — TSP License Artifact v1 offline verifier (ADR-0010).
3
+ *
4
+ * Normative invariant (ADR-0010): a license MUST be verifiable WITHOUT
5
+ * contacting LexiCo. This function performs no network I/O. It validates a
6
+ * `tsp.license-bundle.v1` through the two-tier offline trust hierarchy:
7
+ *
8
+ * license --signed by--> issuer --signed by--> pinned license-root
9
+ *
10
+ * It reuses the TSP cryptographic substrate (canonicalize / Ed25519 / Web
11
+ * Crypto) and is independent of verifyLocal() / the TrustEnvelope schema,
12
+ * which are NOT touched (ADR-0010 Decision 1).
13
+ *
14
+ * Returns a deterministic { ok, reason, detail, ... } in a CLOSED vocabulary.
15
+ * The gateway maps any ok=false to the fail-closed `unlicensed_platform`
16
+ * decision at the 402 path; the granular `reason` is recorded for evidence.
17
+ *
18
+ * Reason vocabulary (closed):
19
+ * ok:true -> 'valid' | 'valid_in_grace'
20
+ * ok:false -> schema_invalid | unsupported_artifact | issuer_mismatch
21
+ * | license_signature_invalid | untrusted_root
22
+ * | issuer_credential_invalid | issuer_not_yet_valid
23
+ * | issuer_expired | license_not_yet_valid | license_expired
24
+ * | origin_mismatch | module_not_licensed
25
+ */
26
+ import { canonicalize } from './canonical.js';
27
+ import { importPublicKeyJwk, verifyEd25519, base64ToBytes } from './crypto.js';
28
+ import { buildLicenseSigningDomain, buildIssuerCredentialSigningDomain } from './license-domain.js';
29
+ import { validateLicenseBundleShape, LICENSE_ARTIFACT_TYPE } from './license-schema.js';
30
+
31
+ const encoder = new TextEncoder();
32
+
33
+ const fail = (reason, detail) => ({ ok: false, reason, detail });
34
+ const pass = (reason, detail, extra) => ({ ok: true, reason, detail, ...extra });
35
+
36
+ const toMs = (value) => {
37
+ if (value instanceof Date) return value.getTime();
38
+ if (typeof value === 'number') return value;
39
+ return Date.parse(value);
40
+ };
41
+
42
+ const verifyCanonicalEd25519 = async (publicJwk, signatureBase64, body) => {
43
+ const publicKey = await importPublicKeyJwk(publicJwk);
44
+ const sigBytes = base64ToBytes(signatureBase64);
45
+ return verifyEd25519(publicKey, sigBytes, encoder.encode(canonicalize(body)));
46
+ };
47
+
48
+ /**
49
+ * @param {object} bundle parsed tsp.license-bundle.v1
50
+ * @param {object} config { origin, trustedRootKeys: [{ rootKeyId, publicKey }], requiredModules?: string[] }
51
+ * @param {Date|string|number} now
52
+ */
53
+ export const verifyLicense = async (bundle, config, now) => {
54
+ // --- config sanity: misconfiguration is a programmer error -> fail-closed at the call site ---
55
+ if (typeof config !== 'object' || config === null) throw new Error('verifyLicense: config must be an object');
56
+ if (typeof config.origin !== 'string' || config.origin.length === 0) {
57
+ throw new Error('verifyLicense: config.origin (configured trust-manifest origin) is required');
58
+ }
59
+ if (!Array.isArray(config.trustedRootKeys) || config.trustedRootKeys.length === 0) {
60
+ throw new Error('verifyLicense: config.trustedRootKeys must be a non-empty pinned root set');
61
+ }
62
+ const nowMs = toMs(now);
63
+ if (Number.isNaN(nowMs)) throw new Error('verifyLicense: `now` is not a valid time');
64
+ const requiredModules = config.requiredModules ?? [];
65
+
66
+ // 1. Structural shape (closed allowlist) — must pass before any signature work.
67
+ const schemaErrors = validateLicenseBundleShape(bundle);
68
+ if (schemaErrors.length > 0) return fail('schema_invalid', schemaErrors.join('; '));
69
+
70
+ const license = bundle.license;
71
+ const cred = bundle.issuerCredential.credential;
72
+
73
+ // 2. Artifact / version gate.
74
+ if (license.artifact_type !== LICENSE_ARTIFACT_TYPE) {
75
+ return fail('unsupported_artifact', `license.artifact_type ${license.artifact_type} is not supported`);
76
+ }
77
+
78
+ // 3. Verify license signature against the BUNDLED issuer public key.
79
+ let licenseSigOk;
80
+ try {
81
+ licenseSigOk = await verifyCanonicalEd25519(
82
+ cred.issuerPublicKey,
83
+ bundle.licenseSignature.signature,
84
+ buildLicenseSigningDomain(license),
85
+ );
86
+ } catch (error) {
87
+ return fail('license_signature_invalid', `license signature could not be verified: ${String(error)}`);
88
+ }
89
+ if (!licenseSigOk) return fail('license_signature_invalid', 'license signature does not verify against the bundled issuer key');
90
+
91
+ // 4. Verify the issuer credential against the PINNED license-root set.
92
+ const root = config.trustedRootKeys.find((r) => r.rootKeyId === cred.rootKeyId);
93
+ if (!root) return fail('untrusted_root', `issuer credential references root "${cred.rootKeyId}" which is not in the pinned root set`);
94
+ let credSigOk;
95
+ try {
96
+ credSigOk = await verifyCanonicalEd25519(
97
+ root.publicKey,
98
+ bundle.issuerCredential.rootSignature.signature,
99
+ buildIssuerCredentialSigningDomain(cred),
100
+ );
101
+ } catch (error) {
102
+ return fail('issuer_credential_invalid', `issuer credential signature could not be verified: ${String(error)}`);
103
+ }
104
+ if (!credSigOk) return fail('issuer_credential_invalid', 'issuer credential does not verify against the pinned license-root');
105
+
106
+ // 5. Issuer<->license binding.
107
+ if (license.issuer_id !== cred.issuer_id) {
108
+ return fail('issuer_mismatch', `license issuer_id "${license.issuer_id}" does not match credential issuer_id "${cred.issuer_id}"`);
109
+ }
110
+
111
+ // 6. Issuer validity window.
112
+ const issuerFrom = toMs(cred.validFrom);
113
+ const issuerUntil = toMs(cred.validUntil);
114
+ if (nowMs < issuerFrom) return fail('issuer_not_yet_valid', `issuer credential not valid until ${cred.validFrom}`);
115
+ if (nowMs > issuerUntil) return fail('issuer_expired', `issuer credential expired at ${cred.validUntil}`);
116
+
117
+ // 7. License validity window, with signed-only grace.
118
+ const licenseFrom = toMs(license.validFrom);
119
+ const licenseUntil = toMs(license.validUntil);
120
+ if (nowMs < licenseFrom) return fail('license_not_yet_valid', `license not valid until ${license.validFrom}`);
121
+ let inGrace = false;
122
+ if (nowMs > licenseUntil) {
123
+ // Grace exists ONLY if explicitly encoded and signed; the verifier never invents it.
124
+ if (license.graceUntil !== undefined && nowMs <= toMs(license.graceUntil)) {
125
+ inGrace = true;
126
+ } else {
127
+ return fail('license_expired', `license expired at ${license.validUntil}${license.graceUntil ? ` (grace ended ${license.graceUntil})` : ''}`);
128
+ }
129
+ }
130
+
131
+ // 8. Per-origin binding (tamper-evident, not copy-proof — ADR-0010 Decision 3).
132
+ const allowed = [license.subject.origin, ...(license.subject.allowedOrigins ?? [])];
133
+ if (!allowed.includes(config.origin)) {
134
+ return fail('origin_mismatch', `license subject origin(s) ${JSON.stringify(allowed)} do not include configured origin "${config.origin}"`);
135
+ }
136
+
137
+ // 9. Module entitlement — default-deny per feature.
138
+ const missing = requiredModules.filter((m) => !license.modules.includes(m));
139
+ if (missing.length > 0) {
140
+ return fail('module_not_licensed', `required module(s) not licensed: ${missing.join(', ')}`);
141
+ }
142
+
143
+ // 10. Deterministic pass.
144
+ return pass(
145
+ inGrace ? 'valid_in_grace' : 'valid',
146
+ inGrace ? `license valid (in signed grace until ${license.graceUntil})` : 'license verified offline',
147
+ {
148
+ inGrace,
149
+ license: {
150
+ license_id: license.license_id,
151
+ issuer_id: license.issuer_id,
152
+ edition: license.edition,
153
+ origin: license.subject.origin,
154
+ modules: license.modules,
155
+ validUntil: license.validUntil,
156
+ graceUntil: license.graceUntil,
157
+ },
158
+ },
159
+ );
160
+ };
@@ -0,0 +1,105 @@
1
+ /**
2
+ * TSP v3.0 local verification — AUDITABLE REFERENCE VERIFIER CORE.
3
+ *
4
+ * This module is a reference verifier core, NOT the normative source of TSP
5
+ * behaviour. Normative authority is the schema + fixtures + threat model in
6
+ * Lexi-TSP/tsp-spec (see ADR-0008, Normative Authority Hierarchy): when this
7
+ * implementation and the spec disagree, this implementation is wrong until the
8
+ * spec is amended by ADR.
9
+ *
10
+ * Conformance is enforced mechanically, not by trust: scripts/conformance/
11
+ * run-v3-fixtures.mjs runs the checksum-pinned tsp-spec fixture suite through
12
+ * verifyLocal() on every push/PR and fails CI on any divergence — including the
13
+ * two ADR-0002 executionProvenance vectors.
14
+ */
15
+ import { canonicalize } from './canonical.js';
16
+ import { importPublicKeyJwk, verifyEd25519, base64ToBytes } from './crypto.js';
17
+ import { buildLedgerDomain, buildSignatureDomain } from './domains.js';
18
+ import { sha256Hex } from './hash.js';
19
+ import { validateTrustEnvelopeShape } from './schema.js';
20
+
21
+ const encoder = new TextEncoder();
22
+
23
+ const PASS = (detail) => ({ status: 'passed', detail });
24
+ const FAIL = (detail, evidence) => ({ status: 'failed', detail, evidence });
25
+ const SKIP = (detail) => ({ status: 'skipped', detail });
26
+
27
+ const initialChecks = () => ({
28
+ schema: SKIP('not yet checked'),
29
+ contentHash: SKIP('not yet checked'),
30
+ ledgerHash: SKIP('not yet checked'),
31
+ manifestFetch: SKIP('local-only mode: manifest fetch not performed'),
32
+ rootSignature: SKIP('local-only mode: root signature not verified'),
33
+ certChain: SKIP('local-only mode: cert chain not validated'),
34
+ certValidity: SKIP('local-only mode: cert validity not checked'),
35
+ revocation: SKIP('local-only mode: revocation not checked'),
36
+ tsa: SKIP('local-only mode: TSA token not verified'),
37
+ signatures: [],
38
+ });
39
+
40
+ export const verifyLocal = async (envelope, { knownPublicKey }) => {
41
+ const checks = initialChecks();
42
+ const warnings = [];
43
+
44
+ const schemaErrors = validateTrustEnvelopeShape(envelope);
45
+ if (schemaErrors.length > 0) {
46
+ checks.schema = FAIL(`schema validation failed: ${schemaErrors.join('; ')}`, schemaErrors);
47
+ return { valid: false, envelope, checks, warnings };
48
+ }
49
+ checks.schema = PASS('schema is well-formed');
50
+
51
+ const expectedContentHash = await sha256Hex(canonicalize(envelope.content.value));
52
+ checks.contentHash =
53
+ expectedContentHash === envelope.content.hash
54
+ ? PASS('content hash matches canonical(value)')
55
+ : FAIL(`content hash mismatch: claimed ${envelope.content.hash}, computed ${expectedContentHash}`);
56
+
57
+ const expectedLedgerHash = await sha256Hex(canonicalize(buildLedgerDomain(envelope)));
58
+ checks.ledgerHash =
59
+ expectedLedgerHash === envelope.ledger.hash
60
+ ? PASS('ledger hash matches canonical(envelope without ledger.hash)')
61
+ : FAIL(`ledger hash mismatch: claimed ${envelope.ledger.hash}, computed ${expectedLedgerHash}`);
62
+
63
+ for (const signature of envelope.signatures) {
64
+ if (signature.algorithm !== 'ed25519') {
65
+ checks.signatures.push(FAIL(`unsupported algorithm: ${signature.algorithm}`));
66
+ continue;
67
+ }
68
+
69
+ let publicKey;
70
+ try {
71
+ publicKey = await importPublicKeyJwk(knownPublicKey);
72
+ } catch (error) {
73
+ checks.signatures.push(FAIL(`could not import known public key: ${String(error)}`));
74
+ continue;
75
+ }
76
+
77
+ let signatureBytes;
78
+ try {
79
+ signatureBytes = base64ToBytes(signature.signature);
80
+ } catch (error) {
81
+ checks.signatures.push(FAIL(`signature is not valid base64: ${String(error)}`));
82
+ continue;
83
+ }
84
+
85
+ const ok = await verifyEd25519(
86
+ publicKey,
87
+ signatureBytes,
88
+ encoder.encode(canonicalize(buildSignatureDomain(envelope))),
89
+ );
90
+
91
+ checks.signatures.push(
92
+ ok
93
+ ? PASS(`signature valid (role=${signature.role}, algorithm=${signature.algorithm})`)
94
+ : FAIL(`signature invalid (role=${signature.role}, algorithm=${signature.algorithm})`),
95
+ );
96
+ }
97
+
98
+ warnings.push('local-only verify: manifest, cert-chain, TSA, DANE, and revocation checks are not performed');
99
+ warnings.push('local-only verify: signature.keyRef is carried but NOT authenticated — key-ref binding is an online-mode property');
100
+
101
+ const requiredChecks = [checks.schema, checks.contentHash, checks.ledgerHash, ...checks.signatures];
102
+ const valid = requiredChecks.every((check) => check.status === 'passed');
103
+
104
+ return { valid, envelope, checks, warnings };
105
+ };