@buildproven/license-core 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vibe Build Lab LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # @buildproven/license-core
2
+
3
+ Shared license signing & verification primitives for BuildProven products.
4
+
5
+ ## What this is
6
+
7
+ A tiny, frozen-contract package that does one thing: deterministic stringify + RSA-SHA256 sign/verify so that every BuildProven product (QA Architect, claude-kit-pro, future products) can validate licenses against the same byte-for-byte format.
8
+
9
+ Used by:
10
+
11
+ - `buildproven-fulfillment` (Vercel) — signs entries, builds signed registries
12
+ - `qa-architect` (npm CLI) — verifies fetched registry against bundled public key
13
+ - `claude-kit-pro` (Claude Code MCP plugin) — same
14
+
15
+ ## Why it exists
16
+
17
+ Before this package, the same crypto code lived in 3 places. Any drift broke signature verification across the product/server boundary. Now there's one source of truth.
18
+
19
+ ## API
20
+
21
+ ```ts
22
+ import {
23
+ // Crypto primitives
24
+ stableStringify,
25
+ signPayload,
26
+ verifyPayload,
27
+ computeHash,
28
+ timingSafeStringEqual,
29
+
30
+ // Payload construction
31
+ normalizeEmail,
32
+ hashEmail,
33
+ buildLicensePayload,
34
+
35
+ // Registry
36
+ buildSignedRegistry,
37
+
38
+ // Validation helpers (pure — no I/O)
39
+ validateRegistryEntry,
40
+ verifyRegistryMetadata,
41
+
42
+ // Key format
43
+ licenseKeyPattern,
44
+ isValidLicenseKey,
45
+ normalizeLicenseKey,
46
+ } from '@buildproven/license-core';
47
+ ```
48
+
49
+ ## Frozen contract
50
+
51
+ The shape of `LicensePayload` and `RegistryEntry` is **frozen** for the v1.x line. Adding fields is a breaking change because shipped customer CLIs reconstruct payloads from registry entries to verify signatures — any field set drift causes silent verification failure.
52
+
53
+ If you need a new field, bump the major and ship as a new package name (`@buildproven/license-core-v2`). The 1.x line continues to be the contract for QA Architect's deployed customers.
54
+
55
+ ## Install
56
+
57
+ ```bash
58
+ npm install @buildproven/license-core
59
+ ```
60
+
61
+ ## Develop
62
+
63
+ ```bash
64
+ npm install
65
+ npm test # vitest, includes golden-vector test against QAA's deployed code
66
+ npm run build # tsup → dist/
67
+ ```
68
+
69
+ ## License
70
+
71
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,211 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ buildLicensePayload: () => buildLicensePayload,
24
+ buildSignedRegistry: () => buildSignedRegistry,
25
+ computeHash: () => computeHash,
26
+ hashEmail: () => hashEmail,
27
+ isValidLicenseKey: () => isValidLicenseKey,
28
+ licenseKeyPattern: () => licenseKeyPattern,
29
+ normalizeEmail: () => normalizeEmail,
30
+ normalizeLicenseKey: () => normalizeLicenseKey,
31
+ signPayload: () => signPayload,
32
+ stableStringify: () => stableStringify,
33
+ timingSafeStringEqual: () => timingSafeStringEqual,
34
+ validateRegistryEntry: () => validateRegistryEntry,
35
+ verifyPayload: () => verifyPayload,
36
+ verifyRegistryMetadata: () => verifyRegistryMetadata
37
+ });
38
+ module.exports = __toCommonJS(index_exports);
39
+
40
+ // src/signing.ts
41
+ var import_crypto = require("crypto");
42
+ function stableStringify(value, seen = /* @__PURE__ */ new WeakSet()) {
43
+ if (value === null || typeof value !== "object") {
44
+ return JSON.stringify(value);
45
+ }
46
+ if (seen.has(value)) {
47
+ throw new Error("Circular reference detected in payload - cannot serialize");
48
+ }
49
+ seen.add(value);
50
+ if (Array.isArray(value)) {
51
+ return `[${value.map((item) => stableStringify(item, seen)).join(",")}]`;
52
+ }
53
+ const keys = Object.keys(value).sort();
54
+ const entries = keys.map(
55
+ (key) => `${JSON.stringify(key)}:${stableStringify(value[key], seen)}`
56
+ );
57
+ return `{${entries.join(",")}}`;
58
+ }
59
+ function signPayload(payload, privateKeyPem) {
60
+ const data = Buffer.from(stableStringify(payload));
61
+ return (0, import_crypto.sign)(null, data, privateKeyPem).toString("base64");
62
+ }
63
+ function verifyPayload(payload, signature, publicKeyPem) {
64
+ try {
65
+ const data = Buffer.from(stableStringify(payload));
66
+ return (0, import_crypto.verify)(null, data, publicKeyPem, Buffer.from(signature, "base64"));
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+ function computeHash(data) {
72
+ return (0, import_crypto.createHash)("sha256").update(data).digest("hex");
73
+ }
74
+ function timingSafeStringEqual(a, b) {
75
+ if (a.length !== b.length) return false;
76
+ let diff = 0;
77
+ for (let i = 0; i < a.length; i++) {
78
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
79
+ }
80
+ return diff === 0;
81
+ }
82
+
83
+ // src/payload.ts
84
+ var import_crypto2 = require("crypto");
85
+ function normalizeEmail(email) {
86
+ if (!email || typeof email !== "string") return null;
87
+ const normalized = email.trim().toLowerCase();
88
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) return null;
89
+ return normalized.length > 0 ? normalized : null;
90
+ }
91
+ function hashEmail(email) {
92
+ const normalized = normalizeEmail(email);
93
+ if (!normalized) return null;
94
+ return (0, import_crypto2.createHash)("sha256").update(normalized).digest("hex");
95
+ }
96
+ function buildLicensePayload(opts) {
97
+ if (!opts.licenseKey || typeof opts.licenseKey !== "string") {
98
+ throw new Error("licenseKey is required and must be a string");
99
+ }
100
+ if (!opts.tier || typeof opts.tier !== "string") {
101
+ throw new Error("tier is required and must be a string");
102
+ }
103
+ if (!opts.issued || typeof opts.issued !== "string") {
104
+ throw new Error("issued is required and must be a string");
105
+ }
106
+ const payload = {
107
+ licenseKey: opts.licenseKey,
108
+ tier: opts.tier,
109
+ isFounder: Boolean(opts.isFounder),
110
+ issued: opts.issued
111
+ };
112
+ if (opts.emailHash) {
113
+ payload.emailHash = opts.emailHash;
114
+ }
115
+ return payload;
116
+ }
117
+
118
+ // src/registry.ts
119
+ function buildSignedRegistry(entries, privateKeyPem, keyId = "default") {
120
+ const now = (/* @__PURE__ */ new Date()).toISOString();
121
+ const entriesStr = stableStringify(entries);
122
+ const registrySignature = signPayload(entries, privateKeyPem);
123
+ const hash = computeHash(entriesStr);
124
+ return {
125
+ _metadata: {
126
+ version: "1.0",
127
+ created: now,
128
+ lastUpdate: now,
129
+ description: "BuildProven license registry \u2014 populated by fulfillment webhook",
130
+ algorithm: "rsa-sha256",
131
+ keyId,
132
+ registrySignature,
133
+ hash,
134
+ totalLicenses: Object.keys(entries).length
135
+ },
136
+ ...entries
137
+ };
138
+ }
139
+
140
+ // src/validator.ts
141
+ function validateRegistryEntry(opts) {
142
+ const { licenseKey, entry, publicKeyPem, userEmailHash } = opts;
143
+ if (entry.emailHash && userEmailHash && !timingSafeStringEqual(userEmailHash, entry.emailHash)) {
144
+ return { valid: false, error: "Email address does not match license registration" };
145
+ }
146
+ const payload = buildLicensePayload({
147
+ licenseKey,
148
+ tier: entry.tier,
149
+ isFounder: entry.isFounder,
150
+ issued: entry.issued,
151
+ emailHash: entry.emailHash
152
+ });
153
+ if (!verifyPayload(payload, entry.signature, publicKeyPem)) {
154
+ return { valid: false, error: "License entry signature verification failed" };
155
+ }
156
+ return {
157
+ valid: true,
158
+ tier: entry.tier,
159
+ isFounder: entry.isFounder,
160
+ customerId: entry.customerId,
161
+ keyId: entry.keyId
162
+ };
163
+ }
164
+ function verifyRegistryMetadata(signedRegistry, publicKeyPem) {
165
+ const { _metadata, ...entries } = signedRegistry;
166
+ if (!_metadata?.registrySignature) {
167
+ throw new Error("Registry missing _metadata.registrySignature");
168
+ }
169
+ if (!verifyPayload(entries, _metadata.registrySignature, publicKeyPem)) {
170
+ throw new Error("Registry signature verification failed");
171
+ }
172
+ if (_metadata.hash) {
173
+ const computed = computeHash(stableStringify(entries));
174
+ if (!timingSafeStringEqual(computed, _metadata.hash)) {
175
+ throw new Error("Registry hash mismatch");
176
+ }
177
+ }
178
+ return entries;
179
+ }
180
+
181
+ // src/key-format.ts
182
+ function licenseKeyPattern(prefix) {
183
+ if (!/^[A-Z0-9]+$/.test(prefix)) {
184
+ throw new Error(`Prefix must be uppercase alphanumeric: ${prefix}`);
185
+ }
186
+ return new RegExp(`^${prefix}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$`);
187
+ }
188
+ function isValidLicenseKey(key, prefix) {
189
+ return licenseKeyPattern(prefix).test(key.trim().toUpperCase());
190
+ }
191
+ function normalizeLicenseKey(key) {
192
+ return key.trim().toUpperCase();
193
+ }
194
+ // Annotate the CommonJS export names for ESM import in node:
195
+ 0 && (module.exports = {
196
+ buildLicensePayload,
197
+ buildSignedRegistry,
198
+ computeHash,
199
+ hashEmail,
200
+ isValidLicenseKey,
201
+ licenseKeyPattern,
202
+ normalizeEmail,
203
+ normalizeLicenseKey,
204
+ signPayload,
205
+ stableStringify,
206
+ timingSafeStringEqual,
207
+ validateRegistryEntry,
208
+ verifyPayload,
209
+ verifyRegistryMetadata
210
+ });
211
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/signing.ts","../src/payload.ts","../src/registry.ts","../src/validator.ts","../src/key-format.ts"],"sourcesContent":["// Crypto primitives\nexport {\n stableStringify,\n signPayload,\n verifyPayload,\n computeHash,\n timingSafeStringEqual,\n} from './signing.js';\n\n// Payload construction\nexport { normalizeEmail, hashEmail, buildLicensePayload } from './payload.js';\n\n// Registry construction\nexport { buildSignedRegistry } from './registry.js';\n\n// Validation helpers (pure — no I/O)\nexport { validateRegistryEntry, verifyRegistryMetadata } from './validator.js';\nexport type { ValidatedEntry, ValidationFailure, ValidationResult } from './validator.js';\n\n// License key format\nexport { licenseKeyPattern, isValidLicenseKey, normalizeLicenseKey } from './key-format.js';\n\n// Types\nexport type {\n Tier,\n LicensePayload,\n RegistryEntry,\n Registry,\n RegistryMetadata,\n SignedRegistry,\n} from './types.js';\n","/**\n * Deterministic stringify + RSA-SHA256 sign/verify primitives.\n *\n * stableStringify must produce byte-identical output to QA Architect's\n * shipped lib/license-signing.js — the deployed CLI in customers' hands\n * uses that exact algorithm. Any divergence here breaks every QAA license\n * issued to date.\n */\n\nimport { sign as cryptoSign, verify as cryptoVerify, createHash } from 'crypto';\n\nexport function stableStringify(value: unknown, seen: WeakSet<object> = new WeakSet()): string {\n if (value === null || typeof value !== 'object') {\n return JSON.stringify(value);\n }\n if (seen.has(value as object)) {\n throw new Error('Circular reference detected in payload - cannot serialize');\n }\n seen.add(value as object);\n\n if (Array.isArray(value)) {\n return `[${value.map((item) => stableStringify(item, seen)).join(',')}]`;\n }\n const keys = Object.keys(value as Record<string, unknown>).sort();\n const entries = keys.map(\n (key) =>\n `${JSON.stringify(key)}:${stableStringify((value as Record<string, unknown>)[key], seen)}`,\n );\n return `{${entries.join(',')}}`;\n}\n\nexport function signPayload(payload: unknown, privateKeyPem: string): string {\n const data = Buffer.from(stableStringify(payload));\n return cryptoSign(null, data, privateKeyPem).toString('base64');\n}\n\nexport function verifyPayload(payload: unknown, signature: string, publicKeyPem: string): boolean {\n try {\n const data = Buffer.from(stableStringify(payload));\n return cryptoVerify(null, data, publicKeyPem, Buffer.from(signature, 'base64'));\n } catch {\n return false;\n }\n}\n\nexport function computeHash(data: string): string {\n return createHash('sha256').update(data).digest('hex');\n}\n\n/**\n * Constant-time string comparison. Same length precondition is checked\n * outside the comparison loop to avoid leaking length info.\n */\nexport function timingSafeStringEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n let diff = 0;\n for (let i = 0; i < a.length; i++) {\n diff |= a.charCodeAt(i) ^ b.charCodeAt(i);\n }\n return diff === 0;\n}\n","/**\n * Email normalization, hashing, and license payload construction.\n *\n * buildLicensePayload is the contract the fulfillment service signs against\n * and that every client must rebuild bit-for-bit before verification. Adding\n * fields here = breaking change.\n */\n\nimport { createHash } from 'crypto';\nimport type { LicensePayload, Tier } from './types.js';\n\nexport function normalizeEmail(email: string): string | null {\n if (!email || typeof email !== 'string') return null;\n const normalized = email.trim().toLowerCase();\n if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(normalized)) return null;\n return normalized.length > 0 ? normalized : null;\n}\n\nexport function hashEmail(email: string): string | null {\n const normalized = normalizeEmail(email);\n if (!normalized) return null;\n return createHash('sha256').update(normalized).digest('hex');\n}\n\nexport function buildLicensePayload(opts: {\n licenseKey: string;\n tier: Tier;\n isFounder: boolean;\n issued: string;\n emailHash?: string | null;\n}): LicensePayload {\n if (!opts.licenseKey || typeof opts.licenseKey !== 'string') {\n throw new Error('licenseKey is required and must be a string');\n }\n if (!opts.tier || typeof opts.tier !== 'string') {\n throw new Error('tier is required and must be a string');\n }\n if (!opts.issued || typeof opts.issued !== 'string') {\n throw new Error('issued is required and must be a string');\n }\n\n const payload: LicensePayload = {\n licenseKey: opts.licenseKey,\n tier: opts.tier,\n isFounder: Boolean(opts.isFounder),\n issued: opts.issued,\n };\n if (opts.emailHash) {\n payload.emailHash = opts.emailHash;\n }\n return payload;\n}\n","/**\n * Build a complete signed registry from a flat entries map.\n *\n * The registry signature covers ONLY the entries — _metadata is excluded.\n * QAA's deployed validator destructures `_metadata` out before verifying,\n * so any change to what's signed will break compatibility.\n */\n\nimport { computeHash, signPayload, stableStringify } from './signing.js';\nimport type { Registry, SignedRegistry } from './types.js';\n\nexport function buildSignedRegistry(\n entries: Registry,\n privateKeyPem: string,\n keyId = 'default',\n): SignedRegistry {\n const now = new Date().toISOString();\n const entriesStr = stableStringify(entries);\n const registrySignature = signPayload(entries, privateKeyPem);\n const hash = computeHash(entriesStr);\n\n return {\n _metadata: {\n version: '1.0',\n created: now,\n lastUpdate: now,\n description: 'BuildProven license registry — populated by fulfillment webhook',\n algorithm: 'rsa-sha256',\n keyId,\n registrySignature,\n hash,\n totalLicenses: Object.keys(entries).length,\n },\n ...entries,\n };\n}\n","/**\n * Pure validation helpers — no I/O, no caching, no env.\n *\n * Both QA Architect's CLI and claude-kit-pro's MCP server use these\n * to verify a registry response. Anything that touches disk, network,\n * or process.env stays in the consuming product. This is the seam\n * that prevents the two validators from drifting apart.\n */\n\nimport { buildLicensePayload } from './payload.js';\nimport { computeHash, stableStringify, timingSafeStringEqual, verifyPayload } from './signing.js';\nimport type { RegistryEntry, SignedRegistry } from './types.js';\n\nexport interface ValidatedEntry {\n valid: true;\n tier: RegistryEntry['tier'];\n isFounder: boolean;\n customerId: string;\n keyId: string;\n}\n\nexport interface ValidationFailure {\n valid: false;\n error: string;\n}\n\nexport type ValidationResult = ValidatedEntry | ValidationFailure;\n\n/**\n * Verify a single registry entry against its embedded signature.\n * Optionally check the user's email hash against the entry's emailHash.\n *\n * Mirrors QAA's validateLicense() field-set exactly:\n * payload = { licenseKey, tier, isFounder, issued, emailHash? }\n */\nexport function validateRegistryEntry(opts: {\n licenseKey: string;\n entry: RegistryEntry;\n publicKeyPem: string;\n /** If supplied, must match entry.emailHash (timing-safe). */\n userEmailHash?: string;\n}): ValidationResult {\n const { licenseKey, entry, publicKeyPem, userEmailHash } = opts;\n\n if (entry.emailHash && userEmailHash && !timingSafeStringEqual(userEmailHash, entry.emailHash)) {\n return { valid: false, error: 'Email address does not match license registration' };\n }\n\n const payload = buildLicensePayload({\n licenseKey,\n tier: entry.tier,\n isFounder: entry.isFounder,\n issued: entry.issued,\n emailHash: entry.emailHash,\n });\n\n if (!verifyPayload(payload, entry.signature, publicKeyPem)) {\n return { valid: false, error: 'License entry signature verification failed' };\n }\n\n return {\n valid: true,\n tier: entry.tier,\n isFounder: entry.isFounder,\n customerId: entry.customerId,\n keyId: entry.keyId,\n };\n}\n\n/**\n * Verify a complete signed registry: registry-level signature + hash check.\n * Returns the entries map (with _metadata stripped) on success, throws on failure.\n *\n * Throws (rather than returning a result) because a registry signature failure\n * should halt validation entirely — clients should not fall back to entries\n * from an unverified registry.\n */\nexport function verifyRegistryMetadata(\n signedRegistry: SignedRegistry,\n publicKeyPem: string,\n): Record<string, RegistryEntry> {\n const { _metadata, ...entries } = signedRegistry;\n\n if (!_metadata?.registrySignature) {\n throw new Error('Registry missing _metadata.registrySignature');\n }\n\n if (!verifyPayload(entries, _metadata.registrySignature, publicKeyPem)) {\n throw new Error('Registry signature verification failed');\n }\n\n if (_metadata.hash) {\n const computed = computeHash(stableStringify(entries));\n if (!timingSafeStringEqual(computed, _metadata.hash)) {\n throw new Error('Registry hash mismatch');\n }\n }\n\n return entries as Record<string, RegistryEntry>;\n}\n","/**\n * Per-product license key format.\n *\n * QAA-XXXX-XXXX-XXXX-XXXX, CKIT-XXXX-XXXX-XXXX-XXXX, etc.\n * One factory so every product validates the same way.\n */\n\nexport function licenseKeyPattern(prefix: string): RegExp {\n if (!/^[A-Z0-9]+$/.test(prefix)) {\n throw new Error(`Prefix must be uppercase alphanumeric: ${prefix}`);\n }\n return new RegExp(`^${prefix}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$`);\n}\n\nexport function isValidLicenseKey(key: string, prefix: string): boolean {\n return licenseKeyPattern(prefix).test(key.trim().toUpperCase());\n}\n\nexport function normalizeLicenseKey(key: string): string {\n return key.trim().toUpperCase();\n}\n"],"mappings":"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","names":["cryptoSign","cryptoVerify","import_crypto"]}
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Deterministic stringify + RSA-SHA256 sign/verify primitives.
3
+ *
4
+ * stableStringify must produce byte-identical output to QA Architect's
5
+ * shipped lib/license-signing.js — the deployed CLI in customers' hands
6
+ * uses that exact algorithm. Any divergence here breaks every QAA license
7
+ * issued to date.
8
+ */
9
+ declare function stableStringify(value: unknown, seen?: WeakSet<object>): string;
10
+ declare function signPayload(payload: unknown, privateKeyPem: string): string;
11
+ declare function verifyPayload(payload: unknown, signature: string, publicKeyPem: string): boolean;
12
+ declare function computeHash(data: string): string;
13
+ /**
14
+ * Constant-time string comparison. Same length precondition is checked
15
+ * outside the comparison loop to avoid leaking length info.
16
+ */
17
+ declare function timingSafeStringEqual(a: string, b: string): boolean;
18
+
19
+ /**
20
+ * Core types for BuildProven license payloads.
21
+ *
22
+ * IMPORTANT: changing the shape of LicensePayload or RegistryEntry
23
+ * breaks signature verification across every product in the field.
24
+ * Treat these as a frozen contract — bump schemaVersion + ship a new
25
+ * major instead of mutating.
26
+ */
27
+ type Tier = 'FREE' | 'PRO';
28
+ /**
29
+ * The payload that is RSA-signed for each license entry.
30
+ * Field set is FROZEN — see comment above.
31
+ */
32
+ interface LicensePayload {
33
+ licenseKey: string;
34
+ tier: Tier;
35
+ isFounder: boolean;
36
+ issued: string;
37
+ /** Optional — only included when emailHash is non-null on the entry. */
38
+ emailHash?: string;
39
+ }
40
+ /**
41
+ * One row in the signed registry.
42
+ * Stored in Vercel KV by the fulfillment service, fetched + verified by clients.
43
+ */
44
+ interface RegistryEntry {
45
+ tier: Tier;
46
+ isFounder: boolean;
47
+ issued: string;
48
+ emailHash: string | null;
49
+ /** RSA-SHA256 base64 signature of the LicensePayload above. */
50
+ signature: string;
51
+ customerId: string;
52
+ /** Identifies which keypair signed this entry — for rotation. */
53
+ keyId: string;
54
+ }
55
+ type Registry = Record<string, RegistryEntry>;
56
+ interface RegistryMetadata {
57
+ version: string;
58
+ created: string;
59
+ lastUpdate: string;
60
+ description: string;
61
+ algorithm: string;
62
+ keyId: string;
63
+ /** RSA-SHA256 base64 signature over the entries (metadata excluded). */
64
+ registrySignature: string;
65
+ /** SHA-256 hex of stableStringify(entries). */
66
+ hash: string;
67
+ totalLicenses: number;
68
+ }
69
+ interface SignedRegistry {
70
+ _metadata: RegistryMetadata;
71
+ [licenseKey: string]: RegistryEntry | RegistryMetadata;
72
+ }
73
+
74
+ /**
75
+ * Email normalization, hashing, and license payload construction.
76
+ *
77
+ * buildLicensePayload is the contract the fulfillment service signs against
78
+ * and that every client must rebuild bit-for-bit before verification. Adding
79
+ * fields here = breaking change.
80
+ */
81
+
82
+ declare function normalizeEmail(email: string): string | null;
83
+ declare function hashEmail(email: string): string | null;
84
+ declare function buildLicensePayload(opts: {
85
+ licenseKey: string;
86
+ tier: Tier;
87
+ isFounder: boolean;
88
+ issued: string;
89
+ emailHash?: string | null;
90
+ }): LicensePayload;
91
+
92
+ /**
93
+ * Build a complete signed registry from a flat entries map.
94
+ *
95
+ * The registry signature covers ONLY the entries — _metadata is excluded.
96
+ * QAA's deployed validator destructures `_metadata` out before verifying,
97
+ * so any change to what's signed will break compatibility.
98
+ */
99
+
100
+ declare function buildSignedRegistry(entries: Registry, privateKeyPem: string, keyId?: string): SignedRegistry;
101
+
102
+ /**
103
+ * Pure validation helpers — no I/O, no caching, no env.
104
+ *
105
+ * Both QA Architect's CLI and claude-kit-pro's MCP server use these
106
+ * to verify a registry response. Anything that touches disk, network,
107
+ * or process.env stays in the consuming product. This is the seam
108
+ * that prevents the two validators from drifting apart.
109
+ */
110
+
111
+ interface ValidatedEntry {
112
+ valid: true;
113
+ tier: RegistryEntry['tier'];
114
+ isFounder: boolean;
115
+ customerId: string;
116
+ keyId: string;
117
+ }
118
+ interface ValidationFailure {
119
+ valid: false;
120
+ error: string;
121
+ }
122
+ type ValidationResult = ValidatedEntry | ValidationFailure;
123
+ /**
124
+ * Verify a single registry entry against its embedded signature.
125
+ * Optionally check the user's email hash against the entry's emailHash.
126
+ *
127
+ * Mirrors QAA's validateLicense() field-set exactly:
128
+ * payload = { licenseKey, tier, isFounder, issued, emailHash? }
129
+ */
130
+ declare function validateRegistryEntry(opts: {
131
+ licenseKey: string;
132
+ entry: RegistryEntry;
133
+ publicKeyPem: string;
134
+ /** If supplied, must match entry.emailHash (timing-safe). */
135
+ userEmailHash?: string;
136
+ }): ValidationResult;
137
+ /**
138
+ * Verify a complete signed registry: registry-level signature + hash check.
139
+ * Returns the entries map (with _metadata stripped) on success, throws on failure.
140
+ *
141
+ * Throws (rather than returning a result) because a registry signature failure
142
+ * should halt validation entirely — clients should not fall back to entries
143
+ * from an unverified registry.
144
+ */
145
+ declare function verifyRegistryMetadata(signedRegistry: SignedRegistry, publicKeyPem: string): Record<string, RegistryEntry>;
146
+
147
+ /**
148
+ * Per-product license key format.
149
+ *
150
+ * QAA-XXXX-XXXX-XXXX-XXXX, CKIT-XXXX-XXXX-XXXX-XXXX, etc.
151
+ * One factory so every product validates the same way.
152
+ */
153
+ declare function licenseKeyPattern(prefix: string): RegExp;
154
+ declare function isValidLicenseKey(key: string, prefix: string): boolean;
155
+ declare function normalizeLicenseKey(key: string): string;
156
+
157
+ export { type LicensePayload, type Registry, type RegistryEntry, type RegistryMetadata, type SignedRegistry, type Tier, type ValidatedEntry, type ValidationFailure, type ValidationResult, buildLicensePayload, buildSignedRegistry, computeHash, hashEmail, isValidLicenseKey, licenseKeyPattern, normalizeEmail, normalizeLicenseKey, signPayload, stableStringify, timingSafeStringEqual, validateRegistryEntry, verifyPayload, verifyRegistryMetadata };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Deterministic stringify + RSA-SHA256 sign/verify primitives.
3
+ *
4
+ * stableStringify must produce byte-identical output to QA Architect's
5
+ * shipped lib/license-signing.js — the deployed CLI in customers' hands
6
+ * uses that exact algorithm. Any divergence here breaks every QAA license
7
+ * issued to date.
8
+ */
9
+ declare function stableStringify(value: unknown, seen?: WeakSet<object>): string;
10
+ declare function signPayload(payload: unknown, privateKeyPem: string): string;
11
+ declare function verifyPayload(payload: unknown, signature: string, publicKeyPem: string): boolean;
12
+ declare function computeHash(data: string): string;
13
+ /**
14
+ * Constant-time string comparison. Same length precondition is checked
15
+ * outside the comparison loop to avoid leaking length info.
16
+ */
17
+ declare function timingSafeStringEqual(a: string, b: string): boolean;
18
+
19
+ /**
20
+ * Core types for BuildProven license payloads.
21
+ *
22
+ * IMPORTANT: changing the shape of LicensePayload or RegistryEntry
23
+ * breaks signature verification across every product in the field.
24
+ * Treat these as a frozen contract — bump schemaVersion + ship a new
25
+ * major instead of mutating.
26
+ */
27
+ type Tier = 'FREE' | 'PRO';
28
+ /**
29
+ * The payload that is RSA-signed for each license entry.
30
+ * Field set is FROZEN — see comment above.
31
+ */
32
+ interface LicensePayload {
33
+ licenseKey: string;
34
+ tier: Tier;
35
+ isFounder: boolean;
36
+ issued: string;
37
+ /** Optional — only included when emailHash is non-null on the entry. */
38
+ emailHash?: string;
39
+ }
40
+ /**
41
+ * One row in the signed registry.
42
+ * Stored in Vercel KV by the fulfillment service, fetched + verified by clients.
43
+ */
44
+ interface RegistryEntry {
45
+ tier: Tier;
46
+ isFounder: boolean;
47
+ issued: string;
48
+ emailHash: string | null;
49
+ /** RSA-SHA256 base64 signature of the LicensePayload above. */
50
+ signature: string;
51
+ customerId: string;
52
+ /** Identifies which keypair signed this entry — for rotation. */
53
+ keyId: string;
54
+ }
55
+ type Registry = Record<string, RegistryEntry>;
56
+ interface RegistryMetadata {
57
+ version: string;
58
+ created: string;
59
+ lastUpdate: string;
60
+ description: string;
61
+ algorithm: string;
62
+ keyId: string;
63
+ /** RSA-SHA256 base64 signature over the entries (metadata excluded). */
64
+ registrySignature: string;
65
+ /** SHA-256 hex of stableStringify(entries). */
66
+ hash: string;
67
+ totalLicenses: number;
68
+ }
69
+ interface SignedRegistry {
70
+ _metadata: RegistryMetadata;
71
+ [licenseKey: string]: RegistryEntry | RegistryMetadata;
72
+ }
73
+
74
+ /**
75
+ * Email normalization, hashing, and license payload construction.
76
+ *
77
+ * buildLicensePayload is the contract the fulfillment service signs against
78
+ * and that every client must rebuild bit-for-bit before verification. Adding
79
+ * fields here = breaking change.
80
+ */
81
+
82
+ declare function normalizeEmail(email: string): string | null;
83
+ declare function hashEmail(email: string): string | null;
84
+ declare function buildLicensePayload(opts: {
85
+ licenseKey: string;
86
+ tier: Tier;
87
+ isFounder: boolean;
88
+ issued: string;
89
+ emailHash?: string | null;
90
+ }): LicensePayload;
91
+
92
+ /**
93
+ * Build a complete signed registry from a flat entries map.
94
+ *
95
+ * The registry signature covers ONLY the entries — _metadata is excluded.
96
+ * QAA's deployed validator destructures `_metadata` out before verifying,
97
+ * so any change to what's signed will break compatibility.
98
+ */
99
+
100
+ declare function buildSignedRegistry(entries: Registry, privateKeyPem: string, keyId?: string): SignedRegistry;
101
+
102
+ /**
103
+ * Pure validation helpers — no I/O, no caching, no env.
104
+ *
105
+ * Both QA Architect's CLI and claude-kit-pro's MCP server use these
106
+ * to verify a registry response. Anything that touches disk, network,
107
+ * or process.env stays in the consuming product. This is the seam
108
+ * that prevents the two validators from drifting apart.
109
+ */
110
+
111
+ interface ValidatedEntry {
112
+ valid: true;
113
+ tier: RegistryEntry['tier'];
114
+ isFounder: boolean;
115
+ customerId: string;
116
+ keyId: string;
117
+ }
118
+ interface ValidationFailure {
119
+ valid: false;
120
+ error: string;
121
+ }
122
+ type ValidationResult = ValidatedEntry | ValidationFailure;
123
+ /**
124
+ * Verify a single registry entry against its embedded signature.
125
+ * Optionally check the user's email hash against the entry's emailHash.
126
+ *
127
+ * Mirrors QAA's validateLicense() field-set exactly:
128
+ * payload = { licenseKey, tier, isFounder, issued, emailHash? }
129
+ */
130
+ declare function validateRegistryEntry(opts: {
131
+ licenseKey: string;
132
+ entry: RegistryEntry;
133
+ publicKeyPem: string;
134
+ /** If supplied, must match entry.emailHash (timing-safe). */
135
+ userEmailHash?: string;
136
+ }): ValidationResult;
137
+ /**
138
+ * Verify a complete signed registry: registry-level signature + hash check.
139
+ * Returns the entries map (with _metadata stripped) on success, throws on failure.
140
+ *
141
+ * Throws (rather than returning a result) because a registry signature failure
142
+ * should halt validation entirely — clients should not fall back to entries
143
+ * from an unverified registry.
144
+ */
145
+ declare function verifyRegistryMetadata(signedRegistry: SignedRegistry, publicKeyPem: string): Record<string, RegistryEntry>;
146
+
147
+ /**
148
+ * Per-product license key format.
149
+ *
150
+ * QAA-XXXX-XXXX-XXXX-XXXX, CKIT-XXXX-XXXX-XXXX-XXXX, etc.
151
+ * One factory so every product validates the same way.
152
+ */
153
+ declare function licenseKeyPattern(prefix: string): RegExp;
154
+ declare function isValidLicenseKey(key: string, prefix: string): boolean;
155
+ declare function normalizeLicenseKey(key: string): string;
156
+
157
+ export { type LicensePayload, type Registry, type RegistryEntry, type RegistryMetadata, type SignedRegistry, type Tier, type ValidatedEntry, type ValidationFailure, type ValidationResult, buildLicensePayload, buildSignedRegistry, computeHash, hashEmail, isValidLicenseKey, licenseKeyPattern, normalizeEmail, normalizeLicenseKey, signPayload, stableStringify, timingSafeStringEqual, validateRegistryEntry, verifyPayload, verifyRegistryMetadata };
package/dist/index.js ADDED
@@ -0,0 +1,171 @@
1
+ // src/signing.ts
2
+ import { sign as cryptoSign, verify as cryptoVerify, createHash } from "crypto";
3
+ function stableStringify(value, seen = /* @__PURE__ */ new WeakSet()) {
4
+ if (value === null || typeof value !== "object") {
5
+ return JSON.stringify(value);
6
+ }
7
+ if (seen.has(value)) {
8
+ throw new Error("Circular reference detected in payload - cannot serialize");
9
+ }
10
+ seen.add(value);
11
+ if (Array.isArray(value)) {
12
+ return `[${value.map((item) => stableStringify(item, seen)).join(",")}]`;
13
+ }
14
+ const keys = Object.keys(value).sort();
15
+ const entries = keys.map(
16
+ (key) => `${JSON.stringify(key)}:${stableStringify(value[key], seen)}`
17
+ );
18
+ return `{${entries.join(",")}}`;
19
+ }
20
+ function signPayload(payload, privateKeyPem) {
21
+ const data = Buffer.from(stableStringify(payload));
22
+ return cryptoSign(null, data, privateKeyPem).toString("base64");
23
+ }
24
+ function verifyPayload(payload, signature, publicKeyPem) {
25
+ try {
26
+ const data = Buffer.from(stableStringify(payload));
27
+ return cryptoVerify(null, data, publicKeyPem, Buffer.from(signature, "base64"));
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+ function computeHash(data) {
33
+ return createHash("sha256").update(data).digest("hex");
34
+ }
35
+ function timingSafeStringEqual(a, b) {
36
+ if (a.length !== b.length) return false;
37
+ let diff = 0;
38
+ for (let i = 0; i < a.length; i++) {
39
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
40
+ }
41
+ return diff === 0;
42
+ }
43
+
44
+ // src/payload.ts
45
+ import { createHash as createHash2 } from "crypto";
46
+ function normalizeEmail(email) {
47
+ if (!email || typeof email !== "string") return null;
48
+ const normalized = email.trim().toLowerCase();
49
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) return null;
50
+ return normalized.length > 0 ? normalized : null;
51
+ }
52
+ function hashEmail(email) {
53
+ const normalized = normalizeEmail(email);
54
+ if (!normalized) return null;
55
+ return createHash2("sha256").update(normalized).digest("hex");
56
+ }
57
+ function buildLicensePayload(opts) {
58
+ if (!opts.licenseKey || typeof opts.licenseKey !== "string") {
59
+ throw new Error("licenseKey is required and must be a string");
60
+ }
61
+ if (!opts.tier || typeof opts.tier !== "string") {
62
+ throw new Error("tier is required and must be a string");
63
+ }
64
+ if (!opts.issued || typeof opts.issued !== "string") {
65
+ throw new Error("issued is required and must be a string");
66
+ }
67
+ const payload = {
68
+ licenseKey: opts.licenseKey,
69
+ tier: opts.tier,
70
+ isFounder: Boolean(opts.isFounder),
71
+ issued: opts.issued
72
+ };
73
+ if (opts.emailHash) {
74
+ payload.emailHash = opts.emailHash;
75
+ }
76
+ return payload;
77
+ }
78
+
79
+ // src/registry.ts
80
+ function buildSignedRegistry(entries, privateKeyPem, keyId = "default") {
81
+ const now = (/* @__PURE__ */ new Date()).toISOString();
82
+ const entriesStr = stableStringify(entries);
83
+ const registrySignature = signPayload(entries, privateKeyPem);
84
+ const hash = computeHash(entriesStr);
85
+ return {
86
+ _metadata: {
87
+ version: "1.0",
88
+ created: now,
89
+ lastUpdate: now,
90
+ description: "BuildProven license registry \u2014 populated by fulfillment webhook",
91
+ algorithm: "rsa-sha256",
92
+ keyId,
93
+ registrySignature,
94
+ hash,
95
+ totalLicenses: Object.keys(entries).length
96
+ },
97
+ ...entries
98
+ };
99
+ }
100
+
101
+ // src/validator.ts
102
+ function validateRegistryEntry(opts) {
103
+ const { licenseKey, entry, publicKeyPem, userEmailHash } = opts;
104
+ if (entry.emailHash && userEmailHash && !timingSafeStringEqual(userEmailHash, entry.emailHash)) {
105
+ return { valid: false, error: "Email address does not match license registration" };
106
+ }
107
+ const payload = buildLicensePayload({
108
+ licenseKey,
109
+ tier: entry.tier,
110
+ isFounder: entry.isFounder,
111
+ issued: entry.issued,
112
+ emailHash: entry.emailHash
113
+ });
114
+ if (!verifyPayload(payload, entry.signature, publicKeyPem)) {
115
+ return { valid: false, error: "License entry signature verification failed" };
116
+ }
117
+ return {
118
+ valid: true,
119
+ tier: entry.tier,
120
+ isFounder: entry.isFounder,
121
+ customerId: entry.customerId,
122
+ keyId: entry.keyId
123
+ };
124
+ }
125
+ function verifyRegistryMetadata(signedRegistry, publicKeyPem) {
126
+ const { _metadata, ...entries } = signedRegistry;
127
+ if (!_metadata?.registrySignature) {
128
+ throw new Error("Registry missing _metadata.registrySignature");
129
+ }
130
+ if (!verifyPayload(entries, _metadata.registrySignature, publicKeyPem)) {
131
+ throw new Error("Registry signature verification failed");
132
+ }
133
+ if (_metadata.hash) {
134
+ const computed = computeHash(stableStringify(entries));
135
+ if (!timingSafeStringEqual(computed, _metadata.hash)) {
136
+ throw new Error("Registry hash mismatch");
137
+ }
138
+ }
139
+ return entries;
140
+ }
141
+
142
+ // src/key-format.ts
143
+ function licenseKeyPattern(prefix) {
144
+ if (!/^[A-Z0-9]+$/.test(prefix)) {
145
+ throw new Error(`Prefix must be uppercase alphanumeric: ${prefix}`);
146
+ }
147
+ return new RegExp(`^${prefix}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$`);
148
+ }
149
+ function isValidLicenseKey(key, prefix) {
150
+ return licenseKeyPattern(prefix).test(key.trim().toUpperCase());
151
+ }
152
+ function normalizeLicenseKey(key) {
153
+ return key.trim().toUpperCase();
154
+ }
155
+ export {
156
+ buildLicensePayload,
157
+ buildSignedRegistry,
158
+ computeHash,
159
+ hashEmail,
160
+ isValidLicenseKey,
161
+ licenseKeyPattern,
162
+ normalizeEmail,
163
+ normalizeLicenseKey,
164
+ signPayload,
165
+ stableStringify,
166
+ timingSafeStringEqual,
167
+ validateRegistryEntry,
168
+ verifyPayload,
169
+ verifyRegistryMetadata
170
+ };
171
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/signing.ts","../src/payload.ts","../src/registry.ts","../src/validator.ts","../src/key-format.ts"],"sourcesContent":["/**\n * Deterministic stringify + RSA-SHA256 sign/verify primitives.\n *\n * stableStringify must produce byte-identical output to QA Architect's\n * shipped lib/license-signing.js — the deployed CLI in customers' hands\n * uses that exact algorithm. Any divergence here breaks every QAA license\n * issued to date.\n */\n\nimport { sign as cryptoSign, verify as cryptoVerify, createHash } from 'crypto';\n\nexport function stableStringify(value: unknown, seen: WeakSet<object> = new WeakSet()): string {\n if (value === null || typeof value !== 'object') {\n return JSON.stringify(value);\n }\n if (seen.has(value as object)) {\n throw new Error('Circular reference detected in payload - cannot serialize');\n }\n seen.add(value as object);\n\n if (Array.isArray(value)) {\n return `[${value.map((item) => stableStringify(item, seen)).join(',')}]`;\n }\n const keys = Object.keys(value as Record<string, unknown>).sort();\n const entries = keys.map(\n (key) =>\n `${JSON.stringify(key)}:${stableStringify((value as Record<string, unknown>)[key], seen)}`,\n );\n return `{${entries.join(',')}}`;\n}\n\nexport function signPayload(payload: unknown, privateKeyPem: string): string {\n const data = Buffer.from(stableStringify(payload));\n return cryptoSign(null, data, privateKeyPem).toString('base64');\n}\n\nexport function verifyPayload(payload: unknown, signature: string, publicKeyPem: string): boolean {\n try {\n const data = Buffer.from(stableStringify(payload));\n return cryptoVerify(null, data, publicKeyPem, Buffer.from(signature, 'base64'));\n } catch {\n return false;\n }\n}\n\nexport function computeHash(data: string): string {\n return createHash('sha256').update(data).digest('hex');\n}\n\n/**\n * Constant-time string comparison. Same length precondition is checked\n * outside the comparison loop to avoid leaking length info.\n */\nexport function timingSafeStringEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n let diff = 0;\n for (let i = 0; i < a.length; i++) {\n diff |= a.charCodeAt(i) ^ b.charCodeAt(i);\n }\n return diff === 0;\n}\n","/**\n * Email normalization, hashing, and license payload construction.\n *\n * buildLicensePayload is the contract the fulfillment service signs against\n * and that every client must rebuild bit-for-bit before verification. Adding\n * fields here = breaking change.\n */\n\nimport { createHash } from 'crypto';\nimport type { LicensePayload, Tier } from './types.js';\n\nexport function normalizeEmail(email: string): string | null {\n if (!email || typeof email !== 'string') return null;\n const normalized = email.trim().toLowerCase();\n if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(normalized)) return null;\n return normalized.length > 0 ? normalized : null;\n}\n\nexport function hashEmail(email: string): string | null {\n const normalized = normalizeEmail(email);\n if (!normalized) return null;\n return createHash('sha256').update(normalized).digest('hex');\n}\n\nexport function buildLicensePayload(opts: {\n licenseKey: string;\n tier: Tier;\n isFounder: boolean;\n issued: string;\n emailHash?: string | null;\n}): LicensePayload {\n if (!opts.licenseKey || typeof opts.licenseKey !== 'string') {\n throw new Error('licenseKey is required and must be a string');\n }\n if (!opts.tier || typeof opts.tier !== 'string') {\n throw new Error('tier is required and must be a string');\n }\n if (!opts.issued || typeof opts.issued !== 'string') {\n throw new Error('issued is required and must be a string');\n }\n\n const payload: LicensePayload = {\n licenseKey: opts.licenseKey,\n tier: opts.tier,\n isFounder: Boolean(opts.isFounder),\n issued: opts.issued,\n };\n if (opts.emailHash) {\n payload.emailHash = opts.emailHash;\n }\n return payload;\n}\n","/**\n * Build a complete signed registry from a flat entries map.\n *\n * The registry signature covers ONLY the entries — _metadata is excluded.\n * QAA's deployed validator destructures `_metadata` out before verifying,\n * so any change to what's signed will break compatibility.\n */\n\nimport { computeHash, signPayload, stableStringify } from './signing.js';\nimport type { Registry, SignedRegistry } from './types.js';\n\nexport function buildSignedRegistry(\n entries: Registry,\n privateKeyPem: string,\n keyId = 'default',\n): SignedRegistry {\n const now = new Date().toISOString();\n const entriesStr = stableStringify(entries);\n const registrySignature = signPayload(entries, privateKeyPem);\n const hash = computeHash(entriesStr);\n\n return {\n _metadata: {\n version: '1.0',\n created: now,\n lastUpdate: now,\n description: 'BuildProven license registry — populated by fulfillment webhook',\n algorithm: 'rsa-sha256',\n keyId,\n registrySignature,\n hash,\n totalLicenses: Object.keys(entries).length,\n },\n ...entries,\n };\n}\n","/**\n * Pure validation helpers — no I/O, no caching, no env.\n *\n * Both QA Architect's CLI and claude-kit-pro's MCP server use these\n * to verify a registry response. Anything that touches disk, network,\n * or process.env stays in the consuming product. This is the seam\n * that prevents the two validators from drifting apart.\n */\n\nimport { buildLicensePayload } from './payload.js';\nimport { computeHash, stableStringify, timingSafeStringEqual, verifyPayload } from './signing.js';\nimport type { RegistryEntry, SignedRegistry } from './types.js';\n\nexport interface ValidatedEntry {\n valid: true;\n tier: RegistryEntry['tier'];\n isFounder: boolean;\n customerId: string;\n keyId: string;\n}\n\nexport interface ValidationFailure {\n valid: false;\n error: string;\n}\n\nexport type ValidationResult = ValidatedEntry | ValidationFailure;\n\n/**\n * Verify a single registry entry against its embedded signature.\n * Optionally check the user's email hash against the entry's emailHash.\n *\n * Mirrors QAA's validateLicense() field-set exactly:\n * payload = { licenseKey, tier, isFounder, issued, emailHash? }\n */\nexport function validateRegistryEntry(opts: {\n licenseKey: string;\n entry: RegistryEntry;\n publicKeyPem: string;\n /** If supplied, must match entry.emailHash (timing-safe). */\n userEmailHash?: string;\n}): ValidationResult {\n const { licenseKey, entry, publicKeyPem, userEmailHash } = opts;\n\n if (entry.emailHash && userEmailHash && !timingSafeStringEqual(userEmailHash, entry.emailHash)) {\n return { valid: false, error: 'Email address does not match license registration' };\n }\n\n const payload = buildLicensePayload({\n licenseKey,\n tier: entry.tier,\n isFounder: entry.isFounder,\n issued: entry.issued,\n emailHash: entry.emailHash,\n });\n\n if (!verifyPayload(payload, entry.signature, publicKeyPem)) {\n return { valid: false, error: 'License entry signature verification failed' };\n }\n\n return {\n valid: true,\n tier: entry.tier,\n isFounder: entry.isFounder,\n customerId: entry.customerId,\n keyId: entry.keyId,\n };\n}\n\n/**\n * Verify a complete signed registry: registry-level signature + hash check.\n * Returns the entries map (with _metadata stripped) on success, throws on failure.\n *\n * Throws (rather than returning a result) because a registry signature failure\n * should halt validation entirely — clients should not fall back to entries\n * from an unverified registry.\n */\nexport function verifyRegistryMetadata(\n signedRegistry: SignedRegistry,\n publicKeyPem: string,\n): Record<string, RegistryEntry> {\n const { _metadata, ...entries } = signedRegistry;\n\n if (!_metadata?.registrySignature) {\n throw new Error('Registry missing _metadata.registrySignature');\n }\n\n if (!verifyPayload(entries, _metadata.registrySignature, publicKeyPem)) {\n throw new Error('Registry signature verification failed');\n }\n\n if (_metadata.hash) {\n const computed = computeHash(stableStringify(entries));\n if (!timingSafeStringEqual(computed, _metadata.hash)) {\n throw new Error('Registry hash mismatch');\n }\n }\n\n return entries as Record<string, RegistryEntry>;\n}\n","/**\n * Per-product license key format.\n *\n * QAA-XXXX-XXXX-XXXX-XXXX, CKIT-XXXX-XXXX-XXXX-XXXX, etc.\n * One factory so every product validates the same way.\n */\n\nexport function licenseKeyPattern(prefix: string): RegExp {\n if (!/^[A-Z0-9]+$/.test(prefix)) {\n throw new Error(`Prefix must be uppercase alphanumeric: ${prefix}`);\n }\n return new RegExp(`^${prefix}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$`);\n}\n\nexport function isValidLicenseKey(key: string, prefix: string): boolean {\n return licenseKeyPattern(prefix).test(key.trim().toUpperCase());\n}\n\nexport function normalizeLicenseKey(key: string): string {\n return key.trim().toUpperCase();\n}\n"],"mappings":"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","names":["createHash"]}
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@buildproven/license-core",
3
+ "version": "1.0.0",
4
+ "description": "Shared license signing & verification primitives for BuildProven products (RSA-SHA256, signed registry).",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
23
+ "test": "vitest run",
24
+ "test:watch": "vitest",
25
+ "typecheck": "tsc --noEmit",
26
+ "format": "prettier --write .",
27
+ "format:check": "prettier --check .",
28
+ "prepublishOnly": "npm run build && npm test"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.0.0",
32
+ "prettier": "^3.0.0",
33
+ "tsup": "^8.0.0",
34
+ "typescript": "^5.4.0",
35
+ "vitest": "^4.0.0"
36
+ },
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "https://github.com/buildproven/buildproven-license-core.git"
46
+ },
47
+ "license": "MIT",
48
+ "keywords": [
49
+ "license",
50
+ "licensing",
51
+ "rsa",
52
+ "rsa-sha256",
53
+ "signature",
54
+ "registry",
55
+ "stripe",
56
+ "saas"
57
+ ],
58
+ "private": false
59
+ }