scxq2-cc 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/dist/verify.js ADDED
@@ -0,0 +1,480 @@
1
+ /**
2
+ * SCXQ2 Pack Verifier (v1) — Deterministic Fail-First Ordering (FROZEN)
3
+ *
4
+ * Implements the reference verification algorithm from the SCXQ2 specification.
5
+ * All error codes and ordering are normative and must not change.
6
+ *
7
+ * @module @asx/scxq2-cc/verify
8
+ * @version 1.0.0
9
+ */
10
+
11
+ import crypto from "crypto";
12
+
13
+ /* =============================================================================
14
+ Known Field Sets (Normative)
15
+ ============================================================================= */
16
+
17
+ const KNOWN_PACK_FIELDS = new Set([
18
+ "@type", "@version", "mode", "encoding", "created_utc",
19
+ "dict", "blocks", "proof", "pack_sha256_canon"
20
+ ]);
21
+
22
+ const KNOWN_DICT_FIELDS = new Set([
23
+ "@type", "@version", "mode", "encoding",
24
+ "source_sha256_utf8", "dict", "dict_sha256_canon", "ops",
25
+ "created_utc", "max_dict", "min_len", "flags"
26
+ ]);
27
+
28
+ const KNOWN_BLOCK_FIELDS = new Set([
29
+ "@type", "@version", "mode", "encoding",
30
+ "lane_id", "source_sha256_utf8", "dict_sha256_canon",
31
+ "b64", "block_sha256_canon", "edges", "ops",
32
+ "created_utc", "original_bytes_utf8"
33
+ ]);
34
+
35
+ const KNOWN_PROOF_FIELDS = new Set([
36
+ "@type", "@version", "engine", "source_sha256_utf8",
37
+ "dict_sha256_canon", "block_sha256_canon", "roundtrip_sha256_utf8", "ok",
38
+ "created_utc", "steps", "recomputed"
39
+ ]);
40
+
41
+ /* =============================================================================
42
+ Default Policy Profile (Canonical)
43
+ ============================================================================= */
44
+
45
+ export const SCXQ2_DEFAULT_POLICY = Object.freeze({
46
+ requireRoundtrip: true,
47
+ requireProof: true,
48
+ maxDictEntries: 65535,
49
+ maxDictEntryUnits: 1048576,
50
+ maxBlocks: 1024,
51
+ maxBlockB64Bytes: 67108864,
52
+ maxOutputUnits: 134217728,
53
+ allowEdges: true,
54
+ allowUnknownPackFields: true,
55
+ allowUnknownBlockFields: true,
56
+ allowedModes: ["SCXQ2-DICT16-B64"],
57
+ allowedEncodings: ["SCXQ2-1"],
58
+ failOnFirstError: true
59
+ });
60
+
61
+ /* =============================================================================
62
+ Error Helpers (Normative Shape)
63
+ ============================================================================= */
64
+
65
+ function err(code, phase, message, at = {}) {
66
+ return {
67
+ "@type": "scxq2.error",
68
+ "@version": "1.0.0",
69
+ code,
70
+ phase,
71
+ severity: "fatal",
72
+ message,
73
+ at
74
+ };
75
+ }
76
+
77
+ function fail(e) {
78
+ return {
79
+ "@type": "scxq2.verify.result",
80
+ "@version": "1.0.0",
81
+ ok: false,
82
+ error: e
83
+ };
84
+ }
85
+
86
+ function ok(packSha, dictSha, blocks) {
87
+ return {
88
+ "@type": "scxq2.verify.result",
89
+ "@version": "1.0.0",
90
+ ok: true,
91
+ pack_sha256_canon: packSha,
92
+ dict_sha256_canon: dictSha,
93
+ blocks
94
+ };
95
+ }
96
+
97
+ /* =============================================================================
98
+ Utilities
99
+ ============================================================================= */
100
+
101
+ function sha256HexUtf8(s) {
102
+ return crypto.createHash("sha256").update(Buffer.from(s, "utf8")).digest("hex");
103
+ }
104
+
105
+ function sortKeysDeep(v) {
106
+ if (Array.isArray(v)) return v.map(sortKeysDeep);
107
+ if (v && typeof v === "object") {
108
+ const out = {};
109
+ for (const k of Object.keys(v).sort()) out[k] = sortKeysDeep(v[k]);
110
+ return out;
111
+ }
112
+ return v;
113
+ }
114
+
115
+ export function canon(obj) {
116
+ return JSON.stringify(sortKeysDeep(obj));
117
+ }
118
+
119
+ function copyMinus(obj, field) {
120
+ const o = { ...obj };
121
+ delete o[field];
122
+ return o;
123
+ }
124
+
125
+ function hasUnknownFields(obj, knownSet) {
126
+ for (const k of Object.keys(obj)) {
127
+ if (!knownSet.has(k)) return true;
128
+ }
129
+ return false;
130
+ }
131
+
132
+ function isoUtcLike(s) {
133
+ return typeof s === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(s);
134
+ }
135
+
136
+ function strictB64Decode(b64) {
137
+ if (typeof b64 !== "string" || b64.length === 0) return null;
138
+ if (!/^[A-Za-z0-9+/=]+$/.test(b64)) return null;
139
+ try {
140
+ return Buffer.from(b64, "base64");
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /* =============================================================================
147
+ SCXQ2 Decode (JS Reference Implementation)
148
+ ============================================================================= */
149
+
150
+ /**
151
+ * Decodes SCXQ2 byte stream to UTF-16 string.
152
+ *
153
+ * @param {string[]} dictArr - Dictionary array
154
+ * @param {Uint8Array|Buffer} bytes - Encoded bytes
155
+ * @param {Object} limits - Decode limits
156
+ * @returns {{ok: true, value: string}|{ok: false, kind: string, byte_offset: number}}
157
+ */
158
+ export function scxq2DecodeUtf16(dictArr, bytes, limits = {}) {
159
+ const maxOut = limits?.maxOutputUnits ?? SCXQ2_DEFAULT_POLICY.maxOutputUnits;
160
+ let out = "";
161
+ let outUnits = 0;
162
+
163
+ const m = bytes.length;
164
+ for (let i = 0; i < m; ) {
165
+ const b = bytes[i];
166
+
167
+ // ASCII literal
168
+ if (b <= 0x7f) {
169
+ out += String.fromCharCode(b);
170
+ outUnits += 1;
171
+ i += 1;
172
+ }
173
+ // DICT ref
174
+ else if (b === 0x80) {
175
+ if (i + 2 >= m) {
176
+ return { ok: false, kind: "truncated_sequence", byte_offset: i };
177
+ }
178
+ const j = (bytes[i + 1] << 8) | bytes[i + 2];
179
+ if (j < 0 || j >= dictArr.length) {
180
+ return { ok: false, kind: "dict_index_oob", byte_offset: i, index: j };
181
+ }
182
+ const tok = dictArr[j];
183
+ if (typeof tok !== "string") {
184
+ return { ok: false, kind: "dict_entry_invalid", byte_offset: i, index: j };
185
+ }
186
+ out += tok;
187
+ outUnits += tok.length;
188
+ i += 3;
189
+ }
190
+ // UTF-16 literal
191
+ else if (b === 0x81) {
192
+ if (i + 2 >= m) {
193
+ return { ok: false, kind: "truncated_sequence", byte_offset: i };
194
+ }
195
+ const u = (bytes[i + 1] << 8) | bytes[i + 2];
196
+ out += String.fromCharCode(u);
197
+ outUnits += 1;
198
+ i += 3;
199
+ }
200
+ // Invalid byte
201
+ else {
202
+ return { ok: false, kind: "invalid_byte", byte_offset: i, byte: b };
203
+ }
204
+
205
+ if (outUnits > maxOut) {
206
+ return { ok: false, kind: "output_limit", byte_offset: Math.min(m - 1, i) };
207
+ }
208
+ }
209
+
210
+ return { ok: true, value: out };
211
+ }
212
+
213
+ function mapDecodeErr(de, lane_id, index) {
214
+ const at = { lane_id: lane_id ?? undefined, index, byte_offset: de.byte_offset };
215
+ switch (de.kind) {
216
+ case "invalid_byte":
217
+ return err("scxq2.error.decode_invalid_byte", "decode", "invalid byte", at);
218
+ case "truncated_sequence":
219
+ return err("scxq2.error.decode_truncated_sequence", "decode", "truncated sequence", at);
220
+ case "dict_index_oob":
221
+ return err("scxq2.error.decode_dict_index_oob", "decode", "dict index out of bounds", at);
222
+ case "dict_entry_invalid":
223
+ return err("scxq2.error.decode_dict_entry_invalid", "decode", "dict entry invalid", at);
224
+ case "output_limit":
225
+ return err("scxq2.error.decode_output_limit", "decode", "output limit exceeded", at);
226
+ default:
227
+ return err("scxq2.error.decode_internal", "decode", "decode internal error", at);
228
+ }
229
+ }
230
+
231
+ /* =============================================================================
232
+ Pack Verifier (Reference Algorithm)
233
+ ============================================================================= */
234
+
235
+ /**
236
+ * Verifies an SCXQ2 pack according to the frozen v1 specification.
237
+ * Follows deterministic fail-first ordering.
238
+ *
239
+ * @param {Object} pack - SCXQ2 pack object
240
+ * @param {Object} [opts] - Policy options (merged with SCXQ2_DEFAULT_POLICY)
241
+ * @returns {Object} Verification result
242
+ */
243
+ export function scxq2PackVerify(pack, opts = {}) {
244
+ const P = { ...SCXQ2_DEFAULT_POLICY, ...(opts || {}) };
245
+
246
+ // =========================================================
247
+ // STEP 1 — PACK STRUCTURE
248
+ // =========================================================
249
+ if (!pack || typeof pack !== "object") {
250
+ return fail(err("scxq2.error.pack_missing", "pack", "pack missing"));
251
+ }
252
+
253
+ if (pack["@type"] !== "scxq2.pack") {
254
+ return fail(err("scxq2.error.pack_type_invalid", "pack", "bad @type", { field: "@type" }));
255
+ }
256
+
257
+ if (pack["@version"] !== "1.0.0") {
258
+ return fail(err("scxq2.error.pack_version_unsupported", "pack", "unsupported @version", { field: "@version" }));
259
+ }
260
+
261
+ if (!P.allowedModes.includes(pack["mode"])) {
262
+ return fail(err("scxq2.error.pack_mode_mismatch", "pack", "mode not allowed", { field: "mode" }));
263
+ }
264
+
265
+ if (!P.allowedEncodings.includes(pack["encoding"])) {
266
+ return fail(err("scxq2.error.pack_encoding_mismatch", "pack", "encoding not allowed", { field: "encoding" }));
267
+ }
268
+
269
+ if (P.allowUnknownPackFields === false && hasUnknownFields(pack, KNOWN_PACK_FIELDS)) {
270
+ return fail(err("scxq2.error.pack_field_forbidden", "pack", "unknown pack field"));
271
+ }
272
+
273
+ if ("created_utc" in pack && !isoUtcLike(pack.created_utc)) {
274
+ return fail(err("scxq2.error.pack_created_utc_invalid", "pack", "invalid created_utc", { field: "created_utc" }));
275
+ }
276
+
277
+ if (!pack.dict) {
278
+ return fail(err("scxq2.error.dict_missing", "dict", "dict missing", { field: "dict" }));
279
+ }
280
+
281
+ if (!Array.isArray(pack.blocks) || pack.blocks.length < 1) {
282
+ return fail(err("scxq2.error.pack_blocks_missing", "pack", "blocks missing/empty", { field: "blocks" }));
283
+ }
284
+
285
+ if (pack.blocks.length > P.maxBlocks) {
286
+ return fail(err("scxq2.error.decode_input_limit", "pack", "too many blocks", { field: "blocks" }));
287
+ }
288
+
289
+ if (P.requireProof && !pack.proof) {
290
+ return fail(err("scxq2.error.pack_proof_missing", "pack", "proof required", { field: "proof" }));
291
+ }
292
+
293
+ // =========================================================
294
+ // STEP 2 — DICT STRUCTURE
295
+ // =========================================================
296
+ const dict = pack.dict;
297
+
298
+ if (!dict || typeof dict !== "object") {
299
+ return fail(err("scxq2.error.dict_type_invalid", "dict", "dict not object", { field: "dict" }));
300
+ }
301
+
302
+ if (dict["@type"] !== "scxq2.dict") {
303
+ return fail(err("scxq2.error.dict_type_invalid", "dict", "bad dict @type", { field: "dict.@type" }));
304
+ }
305
+
306
+ if (dict["@version"] !== "1.0.0") {
307
+ return fail(err("scxq2.error.dict_version_unsupported", "dict", "unsupported dict @version", { field: "dict.@version" }));
308
+ }
309
+
310
+ if (!P.allowedModes.includes(dict.mode)) {
311
+ return fail(err("scxq2.error.pack_mode_mismatch", "dict", "dict mode mismatch", { field: "dict.mode" }));
312
+ }
313
+
314
+ if (!P.allowedEncodings.includes(dict.encoding)) {
315
+ return fail(err("scxq2.error.pack_encoding_mismatch", "dict", "dict encoding mismatch", { field: "dict.encoding" }));
316
+ }
317
+
318
+ if (!Array.isArray(dict.dict)) {
319
+ return fail(err("scxq2.error.dict_entry_type_invalid", "dict", "dict.dict must be array", { field: "dict.dict" }));
320
+ }
321
+
322
+ if (dict.dict.length > P.maxDictEntries) {
323
+ return fail(err("scxq2.error.dict_size_exceeds_limit", "dict", "dict too large", { field: "dict.dict" }));
324
+ }
325
+
326
+ for (let j = 0; j < dict.dict.length; j++) {
327
+ const e = dict.dict[j];
328
+ if (typeof e !== "string") {
329
+ return fail(err("scxq2.error.dict_entry_type_invalid", "dict", "dict entry not string", { field: "dict.dict", index: j }));
330
+ }
331
+ if (e.length > P.maxDictEntryUnits) {
332
+ return fail(err("scxq2.error.dict_entry_exceeds_limit", "dict", "dict entry too long", { field: "dict.dict", index: j }));
333
+ }
334
+ }
335
+
336
+ if (!dict.dict_sha256_canon) {
337
+ return fail(err("scxq2.error.dict_sha_missing", "canon", "dict sha missing", { field: "dict.dict_sha256_canon" }));
338
+ }
339
+
340
+ // =========================================================
341
+ // STEP 3 — BLOCK STRUCTURE
342
+ // =========================================================
343
+ const dictSha = dict.dict_sha256_canon;
344
+
345
+ for (let k = 0; k < pack.blocks.length; k++) {
346
+ const b = pack.blocks[k];
347
+
348
+ if (!b || typeof b !== "object" || b["@type"] !== "scxq2.block") {
349
+ return fail(err("scxq2.error.block_type_invalid", "block", "bad block @type", { field: "blocks", index: k }));
350
+ }
351
+
352
+ if (!P.allowedModes.includes(b.mode)) {
353
+ return fail(err("scxq2.error.block_mode_mismatch", "block", "block mode mismatch", { field: "blocks.mode", index: k }));
354
+ }
355
+
356
+ if (!P.allowedEncodings.includes(b.encoding)) {
357
+ return fail(err("scxq2.error.block_encoding_mismatch", "block", "block encoding mismatch", { field: "blocks.encoding", index: k }));
358
+ }
359
+
360
+ if (typeof b.b64 !== "string" || b.b64.length === 0) {
361
+ return fail(err("scxq2.error.block_b64_missing", "block", "b64 missing", { field: "blocks.b64", index: k }));
362
+ }
363
+
364
+ if (!b.dict_sha256_canon) {
365
+ return fail(err("scxq2.error.block_dict_link_missing", "block", "missing dict link", { field: "blocks.dict_sha256_canon", index: k }));
366
+ }
367
+
368
+ if (b.dict_sha256_canon !== dictSha) {
369
+ return fail(err("scxq2.error.block_dict_link_mismatch", "block", "dict link mismatch", { field: "blocks.dict_sha256_canon", index: k }));
370
+ }
371
+
372
+ if (!b.block_sha256_canon) {
373
+ return fail(err("scxq2.error.block_sha_missing", "canon", "block sha missing", { field: "blocks.block_sha256_canon", index: k }));
374
+ }
375
+
376
+ if (P.requireRoundtrip && !b.source_sha256_utf8) {
377
+ return fail(err("scxq2.error.block_source_sha_missing", "block", "source sha required", { field: "blocks.source_sha256_utf8", index: k }));
378
+ }
379
+
380
+ if (P.allowUnknownBlockFields === false && hasUnknownFields(b, KNOWN_BLOCK_FIELDS)) {
381
+ return fail(err("scxq2.error.pack_field_forbidden", "block", "unknown block field", { field: "blocks", index: k }));
382
+ }
383
+
384
+ if (P.allowEdges === false && "edges" in b) {
385
+ return fail(err("scxq2.error.policy_disabled_feature", "block", "edges not allowed", { field: "blocks.edges", index: k }));
386
+ }
387
+ }
388
+
389
+ // =========================================================
390
+ // STEP 4 — CANONICAL HASH VERIFICATION
391
+ // =========================================================
392
+ if (!pack.pack_sha256_canon) {
393
+ return fail(err("scxq2.error.pack_sha_missing", "canon", "pack sha missing", { field: "pack_sha256_canon" }));
394
+ }
395
+
396
+ {
397
+ const packSha = sha256HexUtf8(canon(copyMinus(pack, "pack_sha256_canon")));
398
+ if (packSha !== pack.pack_sha256_canon) {
399
+ return fail(err("scxq2.error.pack_sha_mismatch", "canon", "pack sha mismatch", { field: "pack_sha256_canon" }));
400
+ }
401
+ }
402
+
403
+ {
404
+ const dictSha2 = sha256HexUtf8(canon(copyMinus(dict, "dict_sha256_canon")));
405
+ if (dictSha2 !== dict.dict_sha256_canon) {
406
+ return fail(err("scxq2.error.dict_sha_mismatch", "canon", "dict sha mismatch", { field: "dict.dict_sha256_canon" }));
407
+ }
408
+ }
409
+
410
+ for (let k = 0; k < pack.blocks.length; k++) {
411
+ const b = pack.blocks[k];
412
+ const bSha2 = sha256HexUtf8(canon(copyMinus(b, "block_sha256_canon")));
413
+ if (bSha2 !== b.block_sha256_canon) {
414
+ return fail(err("scxq2.error.block_sha_mismatch", "canon", "block sha mismatch", { field: "blocks.block_sha256_canon", index: k }));
415
+ }
416
+ }
417
+
418
+ // =========================================================
419
+ // STEP 5 — BASE64 DECODE + DECODE LAW + ROUNDTRIP
420
+ // =========================================================
421
+ for (let k = 0; k < pack.blocks.length; k++) {
422
+ const b = pack.blocks[k];
423
+ const bytes = strictB64Decode(b.b64);
424
+
425
+ if (!bytes) {
426
+ return fail(err("scxq2.error.block_b64_invalid", "block", "invalid base64", { field: "blocks.b64", index: k }));
427
+ }
428
+
429
+ if (bytes.length > P.maxBlockB64Bytes) {
430
+ return fail(err("scxq2.error.decode_input_limit", "decode", "block bytes exceed limit", { field: "blocks.b64", index: k }));
431
+ }
432
+
433
+ const dec = scxq2DecodeUtf16(dict.dict, bytes, { maxOutputUnits: P.maxOutputUnits });
434
+ if (!dec.ok) {
435
+ return fail(mapDecodeErr(dec, b.lane_id, k));
436
+ }
437
+
438
+ if (P.requireRoundtrip) {
439
+ const outSha = sha256HexUtf8(dec.value);
440
+ if (outSha !== b.source_sha256_utf8) {
441
+ return fail(err("scxq2.error.proof_roundtrip_sha_mismatch", "proof", "roundtrip sha mismatch", { lane_id: b.lane_id, index: k }));
442
+ }
443
+ }
444
+ }
445
+
446
+ // =========================================================
447
+ // STEP 6 — PROOF OBJECT
448
+ // =========================================================
449
+ if (P.requireProof) {
450
+ const proof = pack.proof;
451
+
452
+ if (!proof || typeof proof !== "object") {
453
+ return fail(err("scxq2.error.proof_type_invalid", "proof", "proof invalid", { field: "proof" }));
454
+ }
455
+
456
+ if (proof["@type"] !== "cc.proof") {
457
+ return fail(err("scxq2.error.proof_type_invalid", "proof", "bad proof @type", { field: "proof.@type" }));
458
+ }
459
+
460
+ if (proof["@version"] !== "1.0.0") {
461
+ return fail(err("scxq2.error.proof_version_unsupported", "proof", "unsupported proof version", { field: "proof.@version" }));
462
+ }
463
+
464
+ if (proof.ok !== true) {
465
+ return fail(err("scxq2.error.proof_ok_false", "proof", "proof ok false", { field: "proof.ok" }));
466
+ }
467
+
468
+ // Witness presence check
469
+ for (const f of ["engine", "source_sha256_utf8", "dict_sha256_canon", "block_sha256_canon", "roundtrip_sha256_utf8"]) {
470
+ if (!proof[f]) {
471
+ return fail(err("scxq2.error.proof_witness_missing", "proof", "missing proof witness", { field: `proof.${f}` }));
472
+ }
473
+ }
474
+ }
475
+
476
+ // =========================================================
477
+ // SUCCESS
478
+ // =========================================================
479
+ return ok(pack.pack_sha256_canon, dict.dict_sha256_canon, pack.blocks.length);
480
+ }