@massu/core 1.10.3 → 1.10.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1605,7 +1605,7 @@ function addFailureClass(db, opts) {
1605
1605
  return Number(result.lastInsertRowid);
1606
1606
  }
1607
1607
  function getFailureClasses(db) {
1608
- const rows = db.prepare("SELECT * FROM failure_classes ORDER BY name").all();
1608
+ const rows = db.prepare("SELECT * FROM failure_classes ORDER BY name LIMIT 10000").all();
1609
1609
  return rows.map((row) => ({
1610
1610
  id: row.id,
1611
1611
  name: row.name,
@@ -14202,8 +14202,102 @@ var init_init = __esm({
14202
14202
  }
14203
14203
  });
14204
14204
 
14205
+ // src/security/license-pubkey.generated.ts
14206
+ var LICENSE_PUBKEY_ED25519, LICENSE_PUBKEY_FINGERPRINT_HEX, KNOWN_LICENSE_PUBKEY_FINGERPRINTS;
14207
+ var init_license_pubkey_generated = __esm({
14208
+ "src/security/license-pubkey.generated.ts"() {
14209
+ "use strict";
14210
+ LICENSE_PUBKEY_ED25519 = new Uint8Array([39, 136, 108, 146, 85, 233, 119, 252, 223, 226, 123, 155, 234, 168, 200, 150, 36, 249, 174, 6, 130, 146, 125, 196, 136, 224, 202, 150, 53, 228, 114, 15]);
14211
+ LICENSE_PUBKEY_FINGERPRINT_HEX = "18a63d64fdec9e5a368fc45feaa49bed6ced815967e582bc7b8af534f22a9475";
14212
+ KNOWN_LICENSE_PUBKEY_FINGERPRINTS = /* @__PURE__ */ new Set([
14213
+ "18a63d64fdec9e5a368fc45feaa49bed6ced815967e582bc7b8af534f22a9475"
14214
+ ]);
14215
+ }
14216
+ });
14217
+
14218
+ // src/security/license-response-verifier.ts
14219
+ import { createPublicKey } from "crypto";
14220
+ function verifyLicenseResponse(payload) {
14221
+ if (!KNOWN_LICENSE_PUBKEY_FINGERPRINTS.has(LICENSE_PUBKEY_FINGERPRINT_HEX)) {
14222
+ return {
14223
+ kind: "error",
14224
+ reason: `Bundled license pubkey fingerprint ${LICENSE_PUBKEY_FINGERPRINT_HEX} is not in the trusted allowlist. Possible build-time tamper.`
14225
+ };
14226
+ }
14227
+ const sig = payload._signature;
14228
+ const alg = payload._signature_alg;
14229
+ const payloadKeys = payload._signature_payload_keys;
14230
+ const sigPubkey = payload._signature_pubkey_fingerprint;
14231
+ if (typeof sig !== "string" || sig.length === 0) {
14232
+ return { kind: "missing_signature" };
14233
+ }
14234
+ if (alg !== "ed25519") {
14235
+ return { kind: "error", reason: `Unsupported signature algorithm: ${alg}` };
14236
+ }
14237
+ if (!Array.isArray(payloadKeys) || payloadKeys.length === 0) {
14238
+ return { kind: "error", reason: "Missing _signature_payload_keys" };
14239
+ }
14240
+ if (typeof sigPubkey === "string" && sigPubkey !== LICENSE_PUBKEY_FINGERPRINT_HEX) {
14241
+ return { kind: "unknown_pubkey", got: sigPubkey };
14242
+ }
14243
+ const canonicalObj = {};
14244
+ for (const k3 of payloadKeys) {
14245
+ if (typeof k3 !== "string") continue;
14246
+ canonicalObj[k3] = payload[k3];
14247
+ }
14248
+ const canonical = JSON.stringify(canonicalObj, [...payloadKeys].sort());
14249
+ try {
14250
+ const spkiPrefix = Buffer.from([
14251
+ 48,
14252
+ 42,
14253
+ 48,
14254
+ 5,
14255
+ 6,
14256
+ 3,
14257
+ 43,
14258
+ 101,
14259
+ 112,
14260
+ 3,
14261
+ 33,
14262
+ 0
14263
+ ]);
14264
+ const der = Buffer.concat([spkiPrefix, Buffer.from(LICENSE_PUBKEY_ED25519)]);
14265
+ const pubkey = createPublicKey({ key: der, format: "der", type: "spki" });
14266
+ const { verify } = __require("crypto");
14267
+ const ok = verify(
14268
+ null,
14269
+ Buffer.from(canonical, "utf-8"),
14270
+ pubkey,
14271
+ Buffer.from(sig, "base64")
14272
+ );
14273
+ return ok ? { kind: "valid" } : { kind: "bad_signature" };
14274
+ } catch (err) {
14275
+ return {
14276
+ kind: "error",
14277
+ reason: `Signature verification threw: ${err instanceof Error ? err.message : String(err)}`
14278
+ };
14279
+ }
14280
+ }
14281
+ function isLicenseSignatureRequired() {
14282
+ return process.env.MASSU_REQUIRE_SIGNED_LICENSE === "true";
14283
+ }
14284
+ var init_license_response_verifier = __esm({
14285
+ "src/security/license-response-verifier.ts"() {
14286
+ "use strict";
14287
+ init_license_pubkey_generated();
14288
+ }
14289
+ });
14290
+
14205
14291
  // src/license.ts
14206
14292
  import { createHash as createHash5 } from "crypto";
14293
+ function warnLicenseSigOnce(reason) {
14294
+ if (_warnedLicenseSig) return;
14295
+ _warnedLicenseSig = true;
14296
+ process.stderr.write(
14297
+ `[massu] WARNING: license-validate response is unsigned or signature invalid (${reason}). Acceptance permitted under transition mode. Operator: provision Supabase Edge Function LICENSE_RESPONSE_SIGNING_PRIVATE_KEY_B64 then set MASSU_REQUIRE_SIGNED_LICENSE=true to enforce strict mode.
14298
+ `
14299
+ );
14300
+ }
14207
14301
  function tierLevel(tier) {
14208
14302
  return TIER_LEVELS[tier] ?? 0;
14209
14303
  }
@@ -14263,6 +14357,13 @@ async function validateLicense(apiKey) {
14263
14357
  });
14264
14358
  if (response.ok) {
14265
14359
  const data = await response.json();
14360
+ const sigResult = verifyLicenseResponse(data);
14361
+ if (sigResult.kind !== "valid") {
14362
+ if (isLicenseSignatureRequired()) {
14363
+ throw new Error(`License response signature invalid: ${sigResult.kind}`);
14364
+ }
14365
+ warnLicenseSigOnce(sigResult.kind);
14366
+ }
14266
14367
  if (data.valid) {
14267
14368
  const tier = data.plan ? PLAN_TO_TIER_MAP[data.plan] ?? "free" : data.tier ?? "free";
14268
14369
  const validUntil = data.validUntil ?? "";
@@ -14385,12 +14486,14 @@ async function handleLicenseToolCall(name, _args, _memDb) {
14385
14486
  }
14386
14487
  return { content: [{ type: "text", text: `Unknown license tool: ${name}` }] };
14387
14488
  }
14388
- var TIER_LEVELS, TOOL_TIER_MAP, PLAN_TO_TIER_MAP, TIER_LABELS, cachedTier, cachedTierTimestamp, IN_MEMORY_CACHE_TTL_MS;
14489
+ var _warnedLicenseSig, TIER_LEVELS, TOOL_TIER_MAP, PLAN_TO_TIER_MAP, TIER_LABELS, cachedTier, cachedTierTimestamp, IN_MEMORY_CACHE_TTL_MS;
14389
14490
  var init_license = __esm({
14390
14491
  "src/license.ts"() {
14391
14492
  "use strict";
14392
14493
  init_config();
14393
14494
  init_memory_db();
14495
+ init_license_response_verifier();
14496
+ _warnedLicenseSig = false;
14394
14497
  TIER_LEVELS = {
14395
14498
  free: 0,
14396
14499
  pro: 1,
@@ -25469,7 +25572,7 @@ function buildCrossReferences(db) {
25469
25572
  const insertEdge = db.prepare(
25470
25573
  "INSERT OR IGNORE INTO knowledge_edges (source_type, source_id, target_type, target_id, edge_type) VALUES (?, ?, ?, ?, ?)"
25471
25574
  );
25472
- const rules = db.prepare("SELECT rule_id, vr_type, reference_path FROM knowledge_rules").all();
25575
+ const rules = db.prepare("SELECT rule_id, vr_type, reference_path FROM knowledge_rules LIMIT 10000").all();
25473
25576
  for (const rule of rules) {
25474
25577
  if (rule.vr_type && rule.vr_type !== "VR-*") {
25475
25578
  const vrTypes = rule.vr_type.split(/[,\s]+/).filter((v3) => v3.startsWith("VR-"));
@@ -25484,7 +25587,7 @@ function buildCrossReferences(db) {
25484
25587
  edgeCount++;
25485
25588
  }
25486
25589
  }
25487
- const incidents = db.prepare("SELECT incident_num, cr_added FROM knowledge_incidents WHERE cr_added IS NOT NULL").all();
25590
+ const incidents = db.prepare("SELECT incident_num, cr_added FROM knowledge_incidents WHERE cr_added IS NOT NULL LIMIT 10000").all();
25488
25591
  for (const inc of incidents) {
25489
25592
  if (inc.cr_added) {
25490
25593
  const crIds = inc.cr_added.match(/CR-\d+/g) || [];
@@ -25494,7 +25597,7 @@ function buildCrossReferences(db) {
25494
25597
  }
25495
25598
  }
25496
25599
  }
25497
- const chunks = db.prepare("SELECT id, content, metadata FROM knowledge_chunks").all();
25600
+ const chunks = db.prepare("SELECT id, content, metadata FROM knowledge_chunks LIMIT 100000").all();
25498
25601
  for (const chunk of chunks) {
25499
25602
  const text18 = chunk.content;
25500
25603
  const crRefs = text18.match(/CR-\d+/g);
@@ -26108,7 +26211,7 @@ function handleRule(db, args2) {
26108
26211
  lines.push(`- **${rule.rule_id}**: ${rule.rule_text} | VR: ${rule.vr_type || "N/A"} | Ref: ${rule.reference_path || "N/A"}`);
26109
26212
  }
26110
26213
  } else {
26111
- const rules = db.prepare("SELECT * FROM knowledge_rules ORDER BY rule_id").all();
26214
+ const rules = db.prepare("SELECT * FROM knowledge_rules ORDER BY rule_id LIMIT 10000").all();
26112
26215
  lines.push(`## All Canonical Rules (${rules.length} total)`);
26113
26216
  lines.push("");
26114
26217
  for (const rule of rules) {
@@ -26217,7 +26320,7 @@ function handleSchemaCheck(db, args2) {
26217
26320
  }
26218
26321
  }
26219
26322
  if (!table && !column) {
26220
- const all = db.prepare("SELECT * FROM knowledge_schema_mismatches ORDER BY table_name").all();
26323
+ const all = db.prepare("SELECT * FROM knowledge_schema_mismatches ORDER BY table_name LIMIT 10000").all();
26221
26324
  lines.push(`## All Known Schema Mismatches (${all.length} total)`);
26222
26325
  lines.push("");
26223
26326
  lines.push("| Table | WRONG | CORRECT |");
@@ -26355,7 +26458,7 @@ function handleVerification(db, args2) {
26355
26458
  lines.push("");
26356
26459
  }
26357
26460
  } else {
26358
- const all = db.prepare("SELECT vr_type, command, use_when FROM knowledge_verifications ORDER BY vr_type").all();
26461
+ const all = db.prepare("SELECT vr_type, command, use_when FROM knowledge_verifications ORDER BY vr_type LIMIT 10000").all();
26359
26462
  lines.push(`## All Verification Types (${all.length} total)`);
26360
26463
  lines.push("");
26361
26464
  for (const vr of all) {
@@ -26743,7 +26846,7 @@ function handleEffectiveness2(db, args2) {
26743
26846
  const topN = Math.min(args2.top_n || 10, 50);
26744
26847
  const mode = args2.mode || "most_violated";
26745
26848
  const lines = [];
26746
- const allRules = db.prepare("SELECT rule_id, rule_text FROM knowledge_rules ORDER BY rule_id").all();
26849
+ const allRules = db.prepare("SELECT rule_id, rule_text FROM knowledge_rules ORDER BY rule_id LIMIT 10000").all();
26747
26850
  let memoryDb = null;
26748
26851
  const violationCounts = /* @__PURE__ */ new Map();
26749
26852
  const lastViolated = /* @__PURE__ */ new Map();
@@ -1042,7 +1042,7 @@ function initMemorySchema(db) {
1042
1042
  `);
1043
1043
  }
1044
1044
  function getFailureClasses(db) {
1045
- const rows = db.prepare("SELECT * FROM failure_classes ORDER BY name").all();
1045
+ const rows = db.prepare("SELECT * FROM failure_classes ORDER BY name LIMIT 10000").all();
1046
1046
  return rows.map((row) => ({
1047
1047
  id: row.id,
1048
1048
  name: row.name,
@@ -1059,7 +1059,7 @@ function addFailureClass(db, opts) {
1059
1059
  return Number(result.lastInsertRowid);
1060
1060
  }
1061
1061
  function getFailureClasses(db) {
1062
- const rows = db.prepare("SELECT * FROM failure_classes ORDER BY name").all();
1062
+ const rows = db.prepare("SELECT * FROM failure_classes ORDER BY name LIMIT 10000").all();
1063
1063
  return rows.map((row) => ({
1064
1064
  id: row.id,
1065
1065
  name: row.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massu/core",
3
- "version": "1.10.3",
3
+ "version": "1.10.5",
4
4
  "type": "module",
5
5
  "description": "AI Engineering Governance MCP Server - Session memory, knowledge system, feature registry, code intelligence, rule enforcement, tiered tooling (12 free / 73 total), 59 workflow commands, 11 agents, 20+ patterns",
6
6
  "main": "src/server.ts",
@@ -24,7 +24,7 @@
24
24
  "build:bundle-adapters": "tsx scripts/bundle-adapters.ts",
25
25
  "build:cli": "esbuild --bundle --platform=node --format=esm --outfile=dist/cli.js src/cli.ts --external:better-sqlite3 --external:yaml --external:zod --external:chokidar --external:proper-lockfile --external:fsevents --external:web-tree-sitter --external:tweetnacl --external:tar --external:smol-toml --external:vscode-languageserver-protocol --external:@massu/adapter-rails --external:@massu/adapter-phoenix --external:@massu/adapter-aspnet --external:@massu/adapter-spring --external:@massu/adapter-go-chi --banner:js='#!/usr/bin/env node\nimport{createRequire as __cr}from\"module\";const require=__cr(import.meta.url);'",
26
26
  "build:hooks": "esbuild --bundle --platform=node --format=esm --outdir=dist/hooks src/hooks/*.ts --external:better-sqlite3 --external:yaml --external:zod --external:chokidar --external:proper-lockfile --external:fsevents --external:web-tree-sitter --external:tweetnacl --external:tar --external:smol-toml --external:vscode-languageserver-protocol --external:@massu/adapter-rails --external:@massu/adapter-phoenix --external:@massu/adapter-aspnet --external:@massu/adapter-spring --external:@massu/adapter-go-chi --banner:js='import{createRequire as __cr}from\"module\";const require=__cr(import.meta.url);'",
27
- "prepublishOnly": "bash ../../scripts/prepublish-check.sh && node ../../scripts/bundle-pubkey.mjs && npm run build",
27
+ "prepublishOnly": "bash ../../scripts/prepublish-check.sh && node ../../scripts/bundle-pubkey.mjs && node ../../scripts/bundle-license-pubkey.mjs && npm run build",
28
28
  "bench:watch": "tsx test/perf/watch-benchmark.ts"
29
29
  },
30
30
  "dependencies": {
@@ -349,7 +349,7 @@ export function buildCrossReferences(db: Database.Database): number {
349
349
  );
350
350
 
351
351
  // CR -> VR edges (from knowledge_rules)
352
- const rules = db.prepare('SELECT rule_id, vr_type, reference_path FROM knowledge_rules').all() as CRRule[];
352
+ const rules = db.prepare('SELECT rule_id, vr_type, reference_path FROM knowledge_rules LIMIT 10000').all() as CRRule[];
353
353
  for (const rule of rules) {
354
354
  if (rule.vr_type && rule.vr_type !== 'VR-*') {
355
355
  // Split compound VR types (e.g., "VR-SCHEMA" or "VR-*")
@@ -367,7 +367,7 @@ export function buildCrossReferences(db: Database.Database): number {
367
367
  }
368
368
 
369
369
  // Incident -> CR edges (from knowledge_incidents)
370
- const incidents = db.prepare('SELECT incident_num, cr_added FROM knowledge_incidents WHERE cr_added IS NOT NULL').all() as { incident_num: number; cr_added: string }[];
370
+ const incidents = db.prepare('SELECT incident_num, cr_added FROM knowledge_incidents WHERE cr_added IS NOT NULL LIMIT 10000').all() as { incident_num: number; cr_added: string }[];
371
371
  for (const inc of incidents) {
372
372
  if (inc.cr_added) {
373
373
  const crIds = inc.cr_added.match(/CR-\d+/g) || [];
@@ -379,7 +379,7 @@ export function buildCrossReferences(db: Database.Database): number {
379
379
  }
380
380
 
381
381
  // Scan all chunks for cross-references
382
- const chunks = db.prepare('SELECT id, content, metadata FROM knowledge_chunks').all() as { id: number; content: string; metadata: string }[];
382
+ const chunks = db.prepare('SELECT id, content, metadata FROM knowledge_chunks LIMIT 100000').all() as { id: number; content: string; metadata: string }[];
383
383
  for (const chunk of chunks) {
384
384
  const text = chunk.content;
385
385
 
@@ -462,7 +462,7 @@ function handleRule(db: Database.Database, args: Record<string, unknown>): ToolR
462
462
  }
463
463
  } else {
464
464
  // List all rules
465
- const rules = db.prepare('SELECT * FROM knowledge_rules ORDER BY rule_id').all() as {
465
+ const rules = db.prepare('SELECT * FROM knowledge_rules ORDER BY rule_id LIMIT 10000').all() as {
466
466
  rule_id: string; rule_text: string; vr_type: string;
467
467
  }[];
468
468
 
@@ -614,7 +614,7 @@ function handleSchemaCheck(db: Database.Database, args: Record<string, unknown>)
614
614
 
615
615
  if (!table && !column) {
616
616
  // Show all mismatches
617
- const all = db.prepare('SELECT * FROM knowledge_schema_mismatches ORDER BY table_name').all() as {
617
+ const all = db.prepare('SELECT * FROM knowledge_schema_mismatches ORDER BY table_name LIMIT 10000').all() as {
618
618
  table_name: string; wrong_column: string; correct_column: string;
619
619
  }[];
620
620
 
@@ -804,7 +804,7 @@ function handleVerification(db: Database.Database, args: Record<string, unknown>
804
804
  }
805
805
  } else {
806
806
  // List all
807
- const all = db.prepare('SELECT vr_type, command, use_when FROM knowledge_verifications ORDER BY vr_type').all() as {
807
+ const all = db.prepare('SELECT vr_type, command, use_when FROM knowledge_verifications ORDER BY vr_type LIMIT 10000').all() as {
808
808
  vr_type: string; command: string; use_when: string;
809
809
  }[];
810
810
 
@@ -1319,7 +1319,7 @@ function handleEffectiveness(db: Database.Database, args: Record<string, unknown
1319
1319
  const lines: string[] = [];
1320
1320
 
1321
1321
  // Get all CR rules from knowledge DB
1322
- const allRules = db.prepare('SELECT rule_id, rule_text FROM knowledge_rules ORDER BY rule_id').all() as {
1322
+ const allRules = db.prepare('SELECT rule_id, rule_text FROM knowledge_rules ORDER BY rule_id LIMIT 10000').all() as {
1323
1323
  rule_id: string; rule_text: string;
1324
1324
  }[];
1325
1325
 
package/src/license.ts CHANGED
@@ -17,6 +17,21 @@ import { createHash } from 'crypto';
17
17
  import type { ToolDefinition, ToolResult } from './tools.ts';
18
18
  import { getConfig } from './config.ts';
19
19
  import { getMemoryDb } from './memory-db.ts';
20
+ import { verifyLicenseResponse, isLicenseSignatureRequired } from './security/license-response-verifier.ts';
21
+
22
+ // P-H019 one-shot warning gate. We emit the stderr warning at most once
23
+ // per process lifetime so the customer's terminal isn't spammed every
24
+ // session-start until the operator provisions the Edge Function signing key.
25
+ let _warnedLicenseSig = false;
26
+ function warnLicenseSigOnce(reason: string): void {
27
+ if (_warnedLicenseSig) return;
28
+ _warnedLicenseSig = true;
29
+ process.stderr.write(
30
+ `[massu] WARNING: license-validate response is unsigned or signature invalid (${reason}). ` +
31
+ `Acceptance permitted under transition mode. Operator: provision Supabase Edge Function ` +
32
+ `LICENSE_RESPONSE_SIGNING_PRIVATE_KEY_B64 then set MASSU_REQUIRE_SIGNED_LICENSE=true to enforce strict mode.\n`,
33
+ );
34
+ }
20
35
 
21
36
  // ============================================================
22
37
  // Types
@@ -294,8 +309,35 @@ export async function validateLicense(apiKey: string): Promise<LicenseInfo> {
294
309
  validUntil?: string;
295
310
  features?: string[];
296
311
  reason?: string;
312
+ _signature?: string;
313
+ _signature_alg?: string;
314
+ _signature_payload_keys?: readonly string[];
315
+ _signature_pubkey_fingerprint?: string;
297
316
  };
298
317
 
318
+ // P-H019 (plan-stage-c-high-batch / 1.10.5): verify Ed25519
319
+ // signature on the validate-key response. Closes the bug class
320
+ // where MITM / malicious-cloud-endpoint / local SQLite edit
321
+ // could grant arbitrary tier.
322
+ //
323
+ // Transition mode (default): unsigned/invalid-sig responses are
324
+ // accepted with a one-shot stderr warning. Lets existing customers
325
+ // keep working while operators provision the Edge Function signing
326
+ // key.
327
+ //
328
+ // Strict mode (MASSU_REQUIRE_SIGNED_LICENSE=true, post-cutover):
329
+ // unsigned/invalid-sig responses are rejected; caller falls
330
+ // through to the grace-period cache or free tier.
331
+ const sigResult = verifyLicenseResponse(data);
332
+ if (sigResult.kind !== 'valid') {
333
+ if (isLicenseSignatureRequired()) {
334
+ // Strict mode — reject. Caller falls through to grace period.
335
+ throw new Error(`License response signature invalid: ${sigResult.kind}`);
336
+ }
337
+ // Transition mode — one-shot stderr warning per session.
338
+ warnLicenseSigOnce(sigResult.kind);
339
+ }
340
+
299
341
  if (data.valid) {
300
342
  // Map plan name to tier using PLAN_TO_TIER_MAP
301
343
  const tier: ToolTier = data.plan
package/src/memory-db.ts CHANGED
@@ -1497,7 +1497,7 @@ export function addFailureClass(db: Database.Database, opts: AddFailureClassOpts
1497
1497
  * Get all failure classes from the taxonomy.
1498
1498
  */
1499
1499
  export function getFailureClasses(db: Database.Database): FailureClass[] {
1500
- const rows = db.prepare('SELECT * FROM failure_classes ORDER BY name').all() as Array<Record<string, unknown>>;
1500
+ const rows = db.prepare('SELECT * FROM failure_classes ORDER BY name LIMIT 10000').all() as Array<Record<string, unknown>>;
1501
1501
  return rows.map(row => ({
1502
1502
  id: row.id as number,
1503
1503
  name: row.name as string,
@@ -0,0 +1,30 @@
1
+ // AUTO-GENERATED by scripts/bundle-license-pubkey.mjs at 2026-05-17T15:24:33.950Z.
2
+ // Source pem: packages/core/security/license-pubkey.pem
3
+ // RAW-bytes sha256: 18a63d64fdec9e5a368fc45feaa49bed6ced815967e582bc7b8af534f22a9475
4
+ // DO NOT EDIT — regenerate via `node scripts/bundle-license-pubkey.mjs` or
5
+ // the prepublishOnly hook (which runs on every npm publish).
6
+
7
+ /**
8
+ * Ed25519 public key for verifying signed /validate-key responses from
9
+ * massu.ai. Used by packages/core/src/security/license-response-verifier.ts
10
+ * to reject MITM / malicious-cloud-endpoint responses.
11
+ *
12
+ * P-H019 (plan-stage-c-high-batch / 1.10.5).
13
+ *
14
+ * To rotate: regenerate the keypair via Node crypto.generateKeyPairSync,
15
+ * update packages/core/security/license-pubkey.pem with the new public key,
16
+ * append the new RAW-bytes sha256 to KNOWN_PUBKEY_FINGERPRINTS in
17
+ * scripts/bundle-license-pubkey.mjs (DO NOT delete the old entry during
18
+ * the rotation grace window), then run `node scripts/bundle-license-pubkey.mjs`.
19
+ * Operator then updates the Supabase Edge Function env var
20
+ * `LICENSE_RESPONSE_SIGNING_PRIVATE_KEY_B64` with the new private key.
21
+ */
22
+ export const LICENSE_PUBKEY_ED25519: Uint8Array = new Uint8Array([39, 136, 108, 146, 85, 233, 119, 252, 223, 226, 123, 155, 234, 168, 200, 150, 36, 249, 174, 6, 130, 146, 125, 196, 136, 224, 202, 150, 53, 228, 114, 15]);
23
+
24
+ /** Hex sha256 of LICENSE_PUBKEY_ED25519 (raw 32 bytes). */
25
+ export const LICENSE_PUBKEY_FINGERPRINT_HEX = '18a63d64fdec9e5a368fc45feaa49bed6ced815967e582bc7b8af534f22a9475';
26
+
27
+ /** Allowlist of historically-trusted license pubkey fingerprints. */
28
+ export const KNOWN_LICENSE_PUBKEY_FINGERPRINTS: ReadonlySet<string> = new Set([
29
+ "18a63d64fdec9e5a368fc45feaa49bed6ced815967e582bc7b8af534f22a9475"
30
+ ]);
@@ -0,0 +1,149 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * P-H019 (plan-stage-c-high-batch / 1.10.5): Ed25519 verifier for signed
6
+ * /validate-key responses from massu.ai.
7
+ *
8
+ * **Bug class closed**: pre-fix, `packages/core/src/license.ts:280-318`
9
+ * accepted ANY `{ valid: true, plan: 'enterprise' }` JSON over HTTPS from
10
+ * whatever endpoint `config.cloud?.endpoint` pointed to. MITM, malicious
11
+ * cloud.endpoint, or local edit of the cached `license_cache` SQLite row
12
+ * could grant arbitrary tier.
13
+ *
14
+ * **Structural fix**: the server (validate-key Supabase Edge Function)
15
+ * signs every response with Ed25519. The client (this module, called
16
+ * from `license.ts`) verifies the signature against the bundled public
17
+ * key in `license-pubkey.generated.ts`. Invalid / missing signature →
18
+ * reject under strict mode, warn-and-accept under transition mode.
19
+ *
20
+ * **Transition mode** (default): when `MASSU_REQUIRE_SIGNED_LICENSE` env
21
+ * var is NOT `'true'`, unsigned/invalid-sig responses are accepted but
22
+ * a one-shot stderr warning is emitted. Lets existing customers keep
23
+ * working while operators provision the signing key.
24
+ *
25
+ * **Strict mode** (post-cutover): when `MASSU_REQUIRE_SIGNED_LICENSE=true`,
26
+ * unsigned/invalid-sig responses are REJECTED (caller drops to grace-period
27
+ * cached or 'free' tier). Operator flips this after Supabase Edge Function
28
+ * env var `LICENSE_RESPONSE_SIGNING_PRIVATE_KEY_B64` is provisioned and
29
+ * cutover smoke test passes.
30
+ */
31
+
32
+ import { createVerify, createPublicKey } from 'crypto';
33
+ import {
34
+ LICENSE_PUBKEY_ED25519,
35
+ LICENSE_PUBKEY_FINGERPRINT_HEX,
36
+ KNOWN_LICENSE_PUBKEY_FINGERPRINTS,
37
+ } from './license-pubkey.generated.ts';
38
+
39
+ export interface SignedLicenseResponse {
40
+ /** Base64-encoded Ed25519 signature over the canonical-serialized payload. */
41
+ _signature?: string;
42
+ /** Signature algorithm tag (currently always `'ed25519'`). */
43
+ _signature_alg?: string;
44
+ /**
45
+ * Sorted list of top-level keys (excluding the `_signature*` triplet) that
46
+ * were included in the signed payload. Lets the client reconstruct the
47
+ * canonical-serialization input deterministically.
48
+ */
49
+ _signature_payload_keys?: readonly string[];
50
+ /** Hex sha256 of the LICENSE pubkey used for signing (so client can detect rotation). */
51
+ _signature_pubkey_fingerprint?: string;
52
+ // Plus the actual fields (plan, tier, validUntil, features, etc.)
53
+ [key: string]: unknown;
54
+ }
55
+
56
+ export type VerificationResult =
57
+ | { kind: 'valid' }
58
+ | { kind: 'missing_signature' }
59
+ | { kind: 'bad_signature' }
60
+ | { kind: 'unknown_pubkey'; got: string }
61
+ | { kind: 'error'; reason: string };
62
+
63
+ /**
64
+ * Verify a server-signed /validate-key response. Returns a tagged union
65
+ * describing the verification outcome. Caller decides whether to accept
66
+ * based on strict/transition mode.
67
+ */
68
+ export function verifyLicenseResponse(payload: SignedLicenseResponse): VerificationResult {
69
+ // Self-check: confirm the bundled key fingerprint matches the allowlist.
70
+ // CR-9 audit L2 pattern: a future build that swaps the pem to an unknown
71
+ // key refuses to load even if compiled in.
72
+ if (!KNOWN_LICENSE_PUBKEY_FINGERPRINTS.has(LICENSE_PUBKEY_FINGERPRINT_HEX)) {
73
+ return {
74
+ kind: 'error',
75
+ reason: `Bundled license pubkey fingerprint ${LICENSE_PUBKEY_FINGERPRINT_HEX} is not in the trusted allowlist. Possible build-time tamper.`,
76
+ };
77
+ }
78
+
79
+ const sig = payload._signature;
80
+ const alg = payload._signature_alg;
81
+ const payloadKeys = payload._signature_payload_keys;
82
+ const sigPubkey = payload._signature_pubkey_fingerprint;
83
+
84
+ if (typeof sig !== 'string' || sig.length === 0) {
85
+ return { kind: 'missing_signature' };
86
+ }
87
+ if (alg !== 'ed25519') {
88
+ return { kind: 'error', reason: `Unsupported signature algorithm: ${alg}` };
89
+ }
90
+ if (!Array.isArray(payloadKeys) || payloadKeys.length === 0) {
91
+ return { kind: 'error', reason: 'Missing _signature_payload_keys' };
92
+ }
93
+ if (typeof sigPubkey === 'string' && sigPubkey !== LICENSE_PUBKEY_FINGERPRINT_HEX) {
94
+ // Server signed with a different pubkey than we have bundled — rotation
95
+ // mismatch. Caller should fail-open under transition mode (operator
96
+ // hasn't published the new bundle yet) but reject under strict mode.
97
+ return { kind: 'unknown_pubkey', got: sigPubkey };
98
+ }
99
+
100
+ // Reconstruct the canonical serialization the server signed:
101
+ // JSON.stringify({ k1: payload[k1], k2: payload[k2], ... }, payloadKeysSorted)
102
+ // where payloadKeysSorted is the sorted list of NON-underscore-prefixed keys.
103
+ const canonicalObj: Record<string, unknown> = {};
104
+ for (const k of payloadKeys) {
105
+ if (typeof k !== 'string') continue;
106
+ canonicalObj[k] = payload[k];
107
+ }
108
+ const canonical = JSON.stringify(canonicalObj, [...payloadKeys].sort());
109
+
110
+ // Verify signature.
111
+ try {
112
+ // Construct a node KeyObject from the bundled raw Ed25519 pubkey bytes.
113
+ // We use createPublicKey with SubjectPublicKeyInfo DER encoding (44 bytes:
114
+ // 12-byte algorithm OID prefix + 32-byte raw key).
115
+ const spkiPrefix = Buffer.from([
116
+ 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65,
117
+ 0x70, 0x03, 0x21, 0x00,
118
+ ]);
119
+ const der = Buffer.concat([spkiPrefix, Buffer.from(LICENSE_PUBKEY_ED25519)]);
120
+ const pubkey = createPublicKey({ key: der, format: 'der', type: 'spki' });
121
+
122
+ // Ed25519 in node uses `verify` directly (no createVerify needed for ed25519).
123
+ // crypto.verify(algorithm, data, key, signature) — algorithm MUST be null for Ed25519.
124
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
125
+ const { verify } = require('crypto');
126
+ const ok = verify(
127
+ null,
128
+ Buffer.from(canonical, 'utf-8'),
129
+ pubkey,
130
+ Buffer.from(sig, 'base64'),
131
+ );
132
+
133
+ return ok ? { kind: 'valid' } : { kind: 'bad_signature' };
134
+ } catch (err) {
135
+ return {
136
+ kind: 'error',
137
+ reason: `Signature verification threw: ${err instanceof Error ? err.message : String(err)}`,
138
+ };
139
+ }
140
+ }
141
+
142
+ /**
143
+ * P-H019 strict-mode gate. Operator sets `MASSU_REQUIRE_SIGNED_LICENSE=true`
144
+ * AFTER the validate-key Edge Function is provisioned with
145
+ * `LICENSE_RESPONSE_SIGNING_PRIVATE_KEY_B64` and cutover smoke test passes.
146
+ */
147
+ export function isLicenseSignatureRequired(): boolean {
148
+ return process.env.MASSU_REQUIRE_SIGNED_LICENSE === 'true';
149
+ }
@@ -1,4 +1,4 @@
1
- // AUTO-GENERATED by scripts/bundle-pubkey.mjs at 2026-05-17T05:43:58.152Z.
1
+ // AUTO-GENERATED by scripts/bundle-pubkey.mjs at 2026-05-17T15:24:33.893Z.
2
2
  // Source pem: packages/core/security/registry-pubkey.pem
3
3
  // RAW-bytes sha256: 3b6226d036c472e533110d11a7d0cd2773ce1d7d4f1003517d5bd69c5418ed4c
4
4
  // DO NOT EDIT — regenerate via `node scripts/bundle-pubkey.mjs` or