@veritasacta/verify
Advanced tools
| { | ||
| "_comment": "Map an Ed25519 verification_key (lowercase hex) to a human label so the CLI prints 'Signer: <label>' instead of raw hex. Add your desk/issuer keys here, or pass your own file with --known-issuers <path>. This is a display aid layered on --key pinning, never a trust shortcut: an unlabeled key still verifies, and a label is only as good as the map you chose to load.", | ||
| "_example": "0000000000000000000000000000000000000000000000000000000000000000 -> Example Desk (remove this)" | ||
| } |
| { | ||
| "type": "scopeblind.legate.proof-pack.v1", | ||
| "generated_at": "2026-06-22T23:31:19.884Z", | ||
| "period": null, | ||
| "position_blind": true, | ||
| "runtime": { | ||
| "name": "Toms-MacBook-Pro.local", | ||
| "verification_key": "ecfc8fe8e791d84204e4889c2ccae46da41d90c8bb88f028bee1f96790c3ae2a" | ||
| }, | ||
| "mandate": { | ||
| "name": "Global Macro IMA", | ||
| "sha256": "5ac8a3803737c74cbb5221ed0983ac8c1cc28954b42ed75e372ef275fd918df2", | ||
| "mode": "enforce", | ||
| "rule_count": 9, | ||
| "coverage": { | ||
| "local": [ | ||
| "gross-exposure", | ||
| "net-exposure", | ||
| "soft-gross-band", | ||
| "class-gross-rates", | ||
| "class-gross-fx", | ||
| "class-gross-equity", | ||
| "class-gross-credit", | ||
| "class-gross-commodity", | ||
| "dv01-book" | ||
| ], | ||
| "order_context": [], | ||
| "delegated": [] | ||
| } | ||
| }, | ||
| "policy": { | ||
| "name": "Legate default agent mandate", | ||
| "sha256": "650d036c3b05675f3719168a41bf0266482728b5f0156c646b2649cd804c040d", | ||
| "version": "1", | ||
| "rule_count": 5 | ||
| }, | ||
| "book": null, | ||
| "governed_actions": { | ||
| "evaluated": 0, | ||
| "allowed": 0, | ||
| "held": 0, | ||
| "blocked": 0, | ||
| "would_block": 0 | ||
| }, | ||
| "restraint": { | ||
| "blocked": 0, | ||
| "held": 0, | ||
| "by_rule": [] | ||
| }, | ||
| "shadow": { | ||
| "observed": 2, | ||
| "would_block": 0, | ||
| "would_hold": 2, | ||
| "by_rule": [ | ||
| { | ||
| "rule": "gross-exposure", | ||
| "count": 2 | ||
| }, | ||
| { | ||
| "rule": "net-exposure", | ||
| "count": 2 | ||
| }, | ||
| { | ||
| "rule": "class-gross-rates", | ||
| "count": 2 | ||
| }, | ||
| { | ||
| "rule": "class-gross-fx", | ||
| "count": 2 | ||
| }, | ||
| { | ||
| "rule": "class-gross-equity", | ||
| "count": 2 | ||
| }, | ||
| { | ||
| "rule": "class-gross-credit", | ||
| "count": 2 | ||
| }, | ||
| { | ||
| "rule": "class-gross-commodity", | ||
| "count": 2 | ||
| } | ||
| ], | ||
| "first_at": "2026-06-22T23:31:19.814Z", | ||
| "last_at": "2026-06-22T23:31:19.849Z" | ||
| }, | ||
| "session_merkle_root": "66308d59fa859823fe9ff774f4f4e91cb025f391327858c0e4192ab84504346d", | ||
| "receipt_count": 1, | ||
| "signer_kid": "sb:legate:ecfc8fe8", | ||
| "verification_key": "ecfc8fe8e791d84204e4889c2ccae46da41d90c8bb88f028bee1f96790c3ae2a", | ||
| "sha256": "b6c421a1d86cbfe34f9748b3976f2a8a3d3cd64cb6467f07c85e58f43ab7c360", | ||
| "signature": "31f1b2090f74157a55445411c1555141f656a88f089a459488e88e155f37d5a633fa62786d4974699e19536221328882651e6d8f471cc1226450f5d203e8eb0b" | ||
| } |
| { | ||
| "payload": { | ||
| "schema": "scopeblind.macro.alert/1", | ||
| "engine_version": "macro-engine/0.1.0", | ||
| "as_of": "2025-08-23", | ||
| "alert_id": "915720cc951de3dfb99437ebac0362f02947160eca28841e6803c37979019cbb", | ||
| "kind": "market_state_change", | ||
| "severity": "critical", | ||
| "title": "Market state: mixed -> stress", | ||
| "detail": "Daily market-state classification changed (confidence 0.8).", | ||
| "refs": [ | ||
| { | ||
| "schema": "scopeblind.macro.market-state/1", | ||
| "as_of": "2025-08-23", | ||
| "digest": "d9602f7915b69bcbf7f86c0cf1b0387965e1b448918be92339ed96d138449d80" | ||
| }, | ||
| { | ||
| "schema": "scopeblind.macro.market-state/1", | ||
| "as_of": "2025-02-05", | ||
| "digest": "d47770c9fdcbe921596497fc1f63d18f86d8bbef302abc060d4d0e1cb7dc9e53" | ||
| } | ||
| ], | ||
| "budget": { | ||
| "position": 1, | ||
| "max_per_day": 2 | ||
| } | ||
| }, | ||
| "digest": "bb942d2f4fe547ffba9e7eec6518d32d5d61019c6c85da6aad4dfa051b3df460", | ||
| "signature": "a3ddc18d8087ae63a4b4d967d3d12a28db2476ef143f0ed21d2d1b4ecae7187eb8310a2e87ee5534c2250a533deeadb450c6e1003bcf1c43b731f96929738800", | ||
| "verification_key": "e324c4149db27151f65df25d528d3037d3b3bc4165b0d28c0769369cba958ae3" | ||
| } |
| { | ||
| "payload": { | ||
| "schema": "scopeblind.macro.journal-entry/1", | ||
| "engine_version": "macro-engine/0.1.0", | ||
| "as_of": "2025-08-23", | ||
| "author": "demo-model", | ||
| "note": "Regime read stagflation while the daily tape printed stress. Cut gross and lengthened nothing; the book is long the wrong factors here.", | ||
| "references": [ | ||
| { | ||
| "schema": "scopeblind.macro.market-state/1", | ||
| "as_of": "2025-08-23", | ||
| "digest": "f469ddc93ce803456bf6eed265c1ffa94690bb3ed972e9dff95c7dab52972e94" | ||
| }, | ||
| { | ||
| "schema": "scopeblind.macro.vulnerability/1", | ||
| "as_of": "2025-08-23", | ||
| "digest": "d277793511a0dcda199889d8beedbe849146cf0fc4cb216ce1dc77cadf20b237" | ||
| }, | ||
| { | ||
| "schema": "scopeblind.macro.regime-snapshot/1", | ||
| "as_of": "2026-05-01", | ||
| "digest": "27a4b9dd293ae77487e919adbf428e4911da62907c39dd017c50376ad9e77591" | ||
| } | ||
| ], | ||
| "tags": [ | ||
| "risk-off", | ||
| "stagflation", | ||
| "vulnerability" | ||
| ] | ||
| }, | ||
| "digest": "95415964066eaf10e5de3e49dd18763c8c7d79099baa0bbe670b402f52243923", | ||
| "signature": "adce443ba5de6d2e2edea9b5cc19744ae0a7842ad552860c03e895e5393434f4f0448bb5eb5d81f2aae7cbf222494931bfcab30ad465931f073ca2350e042b0c", | ||
| "verification_key": "e324c4149db27151f65df25d528d3037d3b3bc4165b0d28c0769369cba958ae3" | ||
| } |
| { | ||
| "payload": { | ||
| "schema": "scopeblind.macro.market-state/1", | ||
| "engine_version": "macro-engine/0.1.0", | ||
| "as_of": "2025-08-23", | ||
| "universe_digest": "e71b066c5c05624984187e810d8dc3422964825d7cefa5338f738d57a1e1dd76", | ||
| "inputs_digest": "51b8add91569d3da3b761f5c0a1fefba25fabbe486214fe36a6204347cc904e3", | ||
| "pillars": { | ||
| "trend": -2, | ||
| "breadth": 0, | ||
| "liquidity": -2, | ||
| "credit": -2, | ||
| "volatility": -2 | ||
| }, | ||
| "evidence": { | ||
| "trend": { | ||
| "inputs": [ | ||
| { | ||
| "symbol": "SPY", | ||
| "feature": "ma_structure", | ||
| "value": -2, | ||
| "vote": -2 | ||
| } | ||
| ], | ||
| "missing": [ | ||
| "QQQ", | ||
| "ACWI" | ||
| ] | ||
| }, | ||
| "breadth": { | ||
| "inputs": [ | ||
| { | ||
| "symbol": "RSP/SPY", | ||
| "feature": "ret20d_z", | ||
| "value": 0, | ||
| "vote": 0 | ||
| }, | ||
| { | ||
| "symbol": "IWM/SPY", | ||
| "feature": "ret20d_z", | ||
| "value": 0, | ||
| "vote": 0 | ||
| } | ||
| ], | ||
| "missing": [] | ||
| }, | ||
| "liquidity": { | ||
| "inputs": [ | ||
| { | ||
| "symbol": "UUP", | ||
| "feature": "ret20d_z_inv", | ||
| "value": -6.554929, | ||
| "vote": -2 | ||
| }, | ||
| { | ||
| "symbol": "BTCUSD", | ||
| "feature": "ret20d_z", | ||
| "value": -6.478535, | ||
| "vote": -2 | ||
| } | ||
| ], | ||
| "missing": [ | ||
| "EEM/EFA", | ||
| "SMH/SPY" | ||
| ] | ||
| }, | ||
| "credit": { | ||
| "inputs": [ | ||
| { | ||
| "symbol": "HYG/IEF", | ||
| "feature": "ret20d_z", | ||
| "value": -6.352958, | ||
| "vote": -2 | ||
| } | ||
| ], | ||
| "missing": [ | ||
| "LQD/IEF", | ||
| "XLF/SPY" | ||
| ] | ||
| }, | ||
| "volatility": { | ||
| "inputs": [ | ||
| { | ||
| "symbol": "SPY", | ||
| "feature": "rvol20_pctile_1y", | ||
| "value": 0.932806, | ||
| "vote": -2 | ||
| } | ||
| ], | ||
| "missing": [ | ||
| "VIX" | ||
| ] | ||
| } | ||
| }, | ||
| "classification": "stress", | ||
| "confidence": 0.8, | ||
| "would_change": [ | ||
| "robust: no single-pillar one-notch change alters this classification" | ||
| ], | ||
| "notes": [ | ||
| "HYG: HYG/IEF ratio proxies HY OAS direction intraday; true OAS (daily, lagged) comes from FRED BAMLH0A0HYM2 in the regime layer.", | ||
| "RSP: Equal-weight ratio proxies constituent breadth; true %>200dma needs constituent data (licensing-gated).", | ||
| "UUP: DXY proxy; futures DX licensing-gated at this tier.", | ||
| "degraded: missing inputs ACWI, EEM/EFA, LQD/IEF, QQQ, SMH/SPY, VIX, XLF/SPY" | ||
| ] | ||
| }, | ||
| "digest": "f469ddc93ce803456bf6eed265c1ffa94690bb3ed972e9dff95c7dab52972e94", | ||
| "signature": "0f93202e78e81ea84ba36e2c2e787ef493aba32c61140a948553f314a3403f042220ac64cf04e8d22e9044b2e6e9712587b1e023cb0dd796353543609aec380a", | ||
| "verification_key": "e324c4149db27151f65df25d528d3037d3b3bc4165b0d28c0769369cba958ae3" | ||
| } |
| { | ||
| "payload": { | ||
| "schema": "scopeblind.macro.price-snapshot/1", | ||
| "engine_version": "macro-engine/0.1.0", | ||
| "as_of": "2026-06-12T20:00:00Z", | ||
| "source": "Massive Market Data daily settle (delayed feed; not a live tick)", | ||
| "delay_minutes": 15, | ||
| "levels": { | ||
| "rates": { | ||
| "level": 85.77, | ||
| "instrument": "TLT", | ||
| "unit": "price", | ||
| "note": "long-duration UST proxy; loads via DV01, not notional" | ||
| }, | ||
| "equity": { | ||
| "level": 741.75, | ||
| "instrument": "SPY", | ||
| "unit": "price", | ||
| "multiplier": 500, | ||
| "note": "ES notional = level x 500 (x10 index ratio, $50 point value)" | ||
| }, | ||
| "fx_eur": { | ||
| "level": 1.15655, | ||
| "instrument": "EURUSD", | ||
| "unit": "fx_rate" | ||
| }, | ||
| "fx_jpy": { | ||
| "level": 160.215, | ||
| "instrument": "USDJPY", | ||
| "unit": "fx_rate" | ||
| }, | ||
| "credit_ig": { | ||
| "level": 109.01, | ||
| "instrument": "LQD", | ||
| "unit": "price", | ||
| "note": "IG proxy; loads via CS01, not notional" | ||
| }, | ||
| "credit_hy": { | ||
| "level": 79.94, | ||
| "instrument": "HYG", | ||
| "unit": "price", | ||
| "note": "HY proxy; loads via CS01, not notional" | ||
| }, | ||
| "commodity_oil": { | ||
| "level": 125.43, | ||
| "instrument": "USO", | ||
| "unit": "price", | ||
| "note": "crude proxy ETF (roll-affected); provenance only" | ||
| }, | ||
| "commodity_gold": { | ||
| "level": 386.54, | ||
| "instrument": "GLD", | ||
| "unit": "price", | ||
| "note": "gold proxy ETF; provenance only" | ||
| } | ||
| }, | ||
| "coverage": [ | ||
| "commodity_gold", | ||
| "commodity_oil", | ||
| "credit_hy", | ||
| "credit_ig", | ||
| "equity", | ||
| "fx_eur", | ||
| "fx_jpy", | ||
| "rates" | ||
| ], | ||
| "missing": [], | ||
| "notes": [ | ||
| "levels are official daily closes (settle) for the 2026-06-12 session, from a delayed feed", | ||
| "instruments are liquid ETF/FX proxies, not the futures themselves; the snapshot never overstates what it observed", | ||
| "only the equity (ES) factor carries a notional multiplier; the rest are signed provenance and load via DV01/CS01" | ||
| ] | ||
| }, | ||
| "digest": "32a46e47fd2639b9991dfd8133c600c8ed7e9a86b56f3ec5372870c35624c165", | ||
| "signature": "676678d1d4ce516869d80f185bf3fd207d33c02109e4deb4e6fc5a8a2a557ed3ac6c12b3f8f12d55c03b7670a20e457dd86ccfbd5fe994015d0ed2e8f782a50b", | ||
| "verification_key": "e324c4149db27151f65df25d528d3037d3b3bc4165b0d28c0769369cba958ae3" | ||
| } |
| { | ||
| "payload": { | ||
| "schema": "scopeblind.macro.regime-snapshot/1", | ||
| "engine_version": "macro-engine/0.1.0", | ||
| "as_of": "2026-05-01", | ||
| "pillars": { | ||
| "growth": -2, | ||
| "inflation": 2, | ||
| "liquidity": -2, | ||
| "policy_freedom": -2, | ||
| "credit_conditions": -2 | ||
| }, | ||
| "inputs": [ | ||
| { | ||
| "series_id": "ICSA", | ||
| "transform": "diff13w", | ||
| "value": 35123.671753, | ||
| "z": -6.764746, | ||
| "vote": -2, | ||
| "asof_digest": "73fa36d5ba21ff19e82757760ffc56899c4d33ca617cbdf79523e2eb1550f573", | ||
| "latest_vintage": "2026-04-29" | ||
| }, | ||
| { | ||
| "series_id": "PAYEMS", | ||
| "transform": "mom3_ann", | ||
| "value": -0.036344, | ||
| "z": -3.501479, | ||
| "vote": -2, | ||
| "asof_digest": "64ca109e3b7d52348a813e5c7fa3695ce0801b30a9ac8cc2aa7cc248e6292abf", | ||
| "latest_vintage": "2026-04-08" | ||
| }, | ||
| { | ||
| "series_id": "INDPRO", | ||
| "transform": "mom6_ann", | ||
| "value": -0.036233, | ||
| "z": -4.775509, | ||
| "vote": -2, | ||
| "asof_digest": "880c1c9898d45bca6a5d9803d3a687e7702ada5b4dee082d68e685fb8540be82", | ||
| "latest_vintage": "2026-04-17" | ||
| }, | ||
| { | ||
| "series_id": "RSXFS", | ||
| "transform": "mom3_ann", | ||
| "value": -0.036344, | ||
| "z": -3.485408, | ||
| "vote": -2, | ||
| "asof_digest": "87bb798c4e9efdfb41f31a11cc3f49f96fe9db48d753cd5aaa0643f3b4a59e1d", | ||
| "latest_vintage": "2026-04-17" | ||
| }, | ||
| { | ||
| "series_id": "USALOLITONOSTSAM", | ||
| "transform": "diff6m", | ||
| "value": -1.152544, | ||
| "z": -5.744365, | ||
| "vote": -2, | ||
| "asof_digest": "4e7ae6048b479286b801201eebf6db1d28ada976b1841d8b41cb5d95c99d9929", | ||
| "latest_vintage": "2026-04-10" | ||
| }, | ||
| { | ||
| "series_id": "CPILFESL", | ||
| "transform": "mom3_ann", | ||
| "value": 0.069551, | ||
| "z": 3.342129, | ||
| "vote": 2, | ||
| "asof_digest": "b0a91055ce6a6a879010796266ef9236bb911a888a54ce6af7b24e6010183ca3", | ||
| "latest_vintage": "2026-04-14" | ||
| }, | ||
| { | ||
| "series_id": "PCEPILFE", | ||
| "transform": "mom3_ann", | ||
| "value": 0.067001, | ||
| "z": 3.342148, | ||
| "vote": 2, | ||
| "asof_digest": "b53a27bb365e01fa1e2ac25f446930ff6f852c4ed2a6fd2eefff38cc96c9da57", | ||
| "latest_vintage": "2026-04-29" | ||
| }, | ||
| { | ||
| "series_id": "T10YIE", | ||
| "transform": "diff3m", | ||
| "value": 0.153311, | ||
| "z": 5.226471, | ||
| "vote": 2, | ||
| "asof_digest": "b839addb59d3c4a84a0a4b27e6ad47636b83a3729b1cc7144bea8dd58db2ff73", | ||
| "latest_vintage": "2026-05-01" | ||
| }, | ||
| { | ||
| "series_id": "DCOILWTICO", | ||
| "transform": "mom3_ann", | ||
| "value": 0.653636, | ||
| "z": 5.382054, | ||
| "vote": 2, | ||
| "asof_digest": "97178dd3c33fa82c7479b66c6132a7d8d96e47ccc39ffc4ba93d64cc38ab65b8", | ||
| "latest_vintage": "2026-05-01" | ||
| }, | ||
| { | ||
| "series_id": "AHETPI", | ||
| "transform": "mom3_ann", | ||
| "value": 0.067001, | ||
| "z": 3.3094, | ||
| "vote": 2, | ||
| "asof_digest": "783bab21a0e6c7c761d4c62ceab7c582cefa6d050b2e03898a4a62f80971ef90", | ||
| "latest_vintage": "2026-04-08" | ||
| }, | ||
| { | ||
| "series_id": "WALCL", | ||
| "transform": "diff13w", | ||
| "value": -355139.244176, | ||
| "z": -6.539508, | ||
| "vote": -2, | ||
| "asof_digest": "af2dd3799a152fdb8171b203aec25bd0b0e276e87df7be9bdc8a989f48a51346", | ||
| "latest_vintage": "2026-04-25" | ||
| }, | ||
| { | ||
| "series_id": "WTREGEN", | ||
| "transform": "diff13w", | ||
| "value": 98346.28091, | ||
| "z": -6.764746, | ||
| "vote": -2, | ||
| "asof_digest": "0e493e335271dcbeeae69f95d4683b35988eba5248e525fed6151b840f127989", | ||
| "latest_vintage": "2026-04-25" | ||
| }, | ||
| { | ||
| "series_id": "RRPONTSYD", | ||
| "transform": "diff13w", | ||
| "value": 158.625566, | ||
| "z": -5.60151, | ||
| "vote": -2, | ||
| "asof_digest": "62bd49d946bd279358ff87d32d370fa6d0a68c37682b1ba7ad99e3bfd1687daa", | ||
| "latest_vintage": "2026-05-01" | ||
| }, | ||
| { | ||
| "series_id": "M2SL", | ||
| "transform": "mom6_ann", | ||
| "value": -0.036233, | ||
| "z": -4.775509, | ||
| "vote": -2, | ||
| "asof_digest": "8b96e538db1b6659919cb95408ff3d5ee407783a03e7f26e0920aa50123e9a19", | ||
| "latest_vintage": "2026-04-27" | ||
| }, | ||
| { | ||
| "series_id": "DTWEXBGS", | ||
| "transform": "diff3m", | ||
| "value": 7.998853, | ||
| "z": -5.226471, | ||
| "vote": -2, | ||
| "asof_digest": "05d20fa0c9d7d5f2dff3d84ec7ff62579e7eaea87ff66bb50a2a8e869165de5d", | ||
| "latest_vintage": "2026-05-01" | ||
| }, | ||
| { | ||
| "series_id": "DFEDTARU", | ||
| "transform": "level", | ||
| "value": 0.999608, | ||
| "z": null, | ||
| "vote": null, | ||
| "asof_digest": "3922373305be0976cddffb6778d61343e9289d3f6d3fe8039a2ca95a765c735f", | ||
| "latest_vintage": "2026-05-01" | ||
| }, | ||
| { | ||
| "series_id": "DFII10", | ||
| "transform": "diff3m", | ||
| "value": 0.119983, | ||
| "z": -5.226471, | ||
| "vote": -2, | ||
| "asof_digest": "474a4dd5f297eb398235b01334b7fc283979b30c96c1b1ac57351eedc6064cdf", | ||
| "latest_vintage": "2026-05-01" | ||
| }, | ||
| { | ||
| "series_id": "T5YIFR", | ||
| "transform": "level", | ||
| "value": 3.201024, | ||
| "z": null, | ||
| "vote": -2, | ||
| "asof_digest": "b8e076fbcd90990be22e3f6fc53461a74d5f0a4553cb0152b70c4fea50f4530f", | ||
| "latest_vintage": "2026-05-01" | ||
| }, | ||
| { | ||
| "series_id": "BAMLH0A0HYM2", | ||
| "transform": "diff13w", | ||
| "value": 1.110379, | ||
| "z": -5.60151, | ||
| "vote": -2, | ||
| "asof_digest": "8ec3d58658b36d414849b002fef15350817a81ddcb81e9e203a9ada8bd8d1c3f", | ||
| "latest_vintage": "2026-05-01" | ||
| }, | ||
| { | ||
| "series_id": "BAMLC0A0CM", | ||
| "transform": "diff13w", | ||
| "value": 0.073323, | ||
| "z": -5.226471, | ||
| "vote": -2, | ||
| "asof_digest": "370a4e107534d46a1a542d1e16ce9f9f4a17aeeb377f9e5c4481f1e0f3a2facb", | ||
| "latest_vintage": "2026-05-01" | ||
| }, | ||
| { | ||
| "series_id": "NFCI", | ||
| "transform": "level", | ||
| "value": 0.550016, | ||
| "z": null, | ||
| "vote": -2, | ||
| "asof_digest": "f0f61455659b69270627f8c5cf767fc2f5dc72985c36d682edad857629df4d9d", | ||
| "latest_vintage": "2026-04-29" | ||
| }, | ||
| { | ||
| "series_id": "NET_LIQUIDITY", | ||
| "transform": "diff13w", | ||
| "value": -650.297517, | ||
| "z": -6.328823, | ||
| "vote": -2, | ||
| "asof_digest": "1e43b543c63afcf7659de4c5ddaf99ba96dc4d1c7f55052aa392b0aadd1207a9", | ||
| "latest_vintage": "2026-04-25" | ||
| }, | ||
| { | ||
| "series_id": "REAL_POLICY_RATE", | ||
| "transform": "level", | ||
| "value": -3.905295, | ||
| "z": null, | ||
| "vote": -2, | ||
| "asof_digest": "9427fbf69d076b6f3a9536742e8d4f2ac15c4e4085075a8b60273d34970b0f4d", | ||
| "latest_vintage": "2026-05-01" | ||
| } | ||
| ], | ||
| "vintage_digest": "77304f7e1203df3211824c78c7c9e800a0dd52b744f5173324524174ad96d3bc", | ||
| "candidate_regime": "stagflation", | ||
| "regime": "stagflation", | ||
| "liquidity_overlay": "contraction", | ||
| "hysteresis": { | ||
| "current": "stagflation", | ||
| "candidate": null, | ||
| "candidate_weeks": 0, | ||
| "weeks_since_switch": 999 | ||
| }, | ||
| "transition": null, | ||
| "playbook": { | ||
| "prefer": [ | ||
| "energy", | ||
| "gold", | ||
| "dollar", | ||
| "defensives", | ||
| "cash" | ||
| ], | ||
| "avoid": [ | ||
| "unhedged cyclicals", | ||
| "levered credit" | ||
| ], | ||
| "confidence": "medium" | ||
| }, | ||
| "confidence": 0.956522, | ||
| "would_change": [ | ||
| "robust: no single-pillar one-notch change alters this candidate regime" | ||
| ], | ||
| "notes": [ | ||
| "degraded: no usable vote from DFEDTARU", | ||
| "Net liquidity (WALCL - TGA - RRP) validity is regime-dependent: strongest when reserve scarcity binds.", | ||
| "PMI family is licensing-gated: growth diffusion uses claims, payrolls, production, retail, and OECD CLI." | ||
| ] | ||
| }, | ||
| "digest": "27a4b9dd293ae77487e919adbf428e4911da62907c39dd017c50376ad9e77591", | ||
| "signature": "806c531043618f2c4da47180ace2020def35fbf12bbf603061e4fb3264b97940c2bcfd50e395d16c8f2e45bdbe2ff69a84c00ddbe330fcaa29bc95296da66207", | ||
| "verification_key": "e324c4149db27151f65df25d528d3037d3b3bc4165b0d28c0769369cba958ae3" | ||
| } |
| { | ||
| "payload": { | ||
| "schema": "scopeblind.macro.tape-snapshot/1", | ||
| "engine_version": "macro-engine/0.1.0", | ||
| "as_of": "2025-08-23", | ||
| "session": "daily_close", | ||
| "tape_type": "mixed", | ||
| "coherence": 0, | ||
| "material": true, | ||
| "signals": [ | ||
| { | ||
| "id": "breadth_rel", | ||
| "z": 0 | ||
| }, | ||
| { | ||
| "id": "credit", | ||
| "z": -2.631816 | ||
| }, | ||
| { | ||
| "id": "dollar", | ||
| "z": 3.61392 | ||
| }, | ||
| { | ||
| "id": "duration", | ||
| "z": 3.20995 | ||
| }, | ||
| { | ||
| "id": "equity", | ||
| "z": -3.860863 | ||
| }, | ||
| { | ||
| "id": "high_beta", | ||
| "z": -4.467346 | ||
| }, | ||
| { | ||
| "id": "smallcap_rel", | ||
| "z": 0 | ||
| } | ||
| ], | ||
| "unavailable": [ | ||
| "banks_rel", | ||
| "copper", | ||
| "gold", | ||
| "growth_beta", | ||
| "oil" | ||
| ], | ||
| "untestable_types": [ | ||
| "inflation_shock" | ||
| ], | ||
| "attribution": { | ||
| "tier": "unknown", | ||
| "detail": "material moves without a coherent cross-asset pattern or scheduled catalyst", | ||
| "events": [] | ||
| }, | ||
| "inputs_digest": "c202156a133cdee47311f843fbc18082311ca653dd4293496ea1c47346fa68c7", | ||
| "would_change": [ | ||
| "a coherent cross-asset pattern forming would move this off mixed" | ||
| ], | ||
| "notes": [ | ||
| "degraded: unavailable signals banks_rel, copper, gold, growth_beta, oil", | ||
| "untestable tape types with current data: inflation_shock", | ||
| "session basis: daily close-to-close; intraday windows plug into the same rules when a live feed is configured" | ||
| ] | ||
| }, | ||
| "digest": "43e98d770cb3b4e47c915164d7fe0aa7ec257968978a5cd1aa914e9e2403decd", | ||
| "signature": "f7cf06b796186a7a6b76053f30b5dd3b96affb6b3853df78550719576d0059bc4a36d34c74afd3d122770e34cbdda03e3f0adcd794c314396a411d3ee5f2590f", | ||
| "verification_key": "e324c4149db27151f65df25d528d3037d3b3bc4165b0d28c0769369cba958ae3" | ||
| } |
| { | ||
| "schema": "scopeblind.macro.track-record-bundle/1", | ||
| "version": "0.1.0", | ||
| "exported_at": "2026-05-01T00:00:00Z", | ||
| "model_verification_key": "e324c4149db27151f65df25d528d3037d3b3bc4165b0d28c0769369cba958ae3", | ||
| "custody": "dev-deterministic", | ||
| "period": { | ||
| "from": "2025-01-02", | ||
| "to": "2026-05-01" | ||
| }, | ||
| "snapshots": [ | ||
| { | ||
| "payload": { | ||
| "schema": "scopeblind.macro.market-state/1", | ||
| "engine_version": "macro-engine/0.1.0", | ||
| "as_of": "2025-08-23", | ||
| "universe_digest": "e71b066c5c05624984187e810d8dc3422964825d7cefa5338f738d57a1e1dd76", | ||
| "inputs_digest": "51b8add91569d3da3b761f5c0a1fefba25fabbe486214fe36a6204347cc904e3", | ||
| "pillars": { | ||
| "trend": -2, | ||
| "breadth": 0, | ||
| "liquidity": -2, | ||
| "credit": -2, | ||
| "volatility": -2 | ||
| }, | ||
| "evidence": { | ||
| "trend": { | ||
| "inputs": [ | ||
| { | ||
| "symbol": "SPY", | ||
| "feature": "ma_structure", | ||
| "value": -2, | ||
| "vote": -2 | ||
| } | ||
| ], | ||
| "missing": [ | ||
| "QQQ", | ||
| "ACWI" | ||
| ] | ||
| }, | ||
| "breadth": { | ||
| "inputs": [ | ||
| { | ||
| "symbol": "RSP/SPY", | ||
| "feature": "ret20d_z", | ||
| "value": 0, | ||
| "vote": 0 | ||
| }, | ||
| { | ||
| "symbol": "IWM/SPY", | ||
| "feature": "ret20d_z", | ||
| "value": 0, | ||
| "vote": 0 | ||
| } | ||
| ], | ||
| "missing": [] | ||
| }, | ||
| "liquidity": { | ||
| "inputs": [ | ||
| { | ||
| "symbol": "UUP", | ||
| "feature": "ret20d_z_inv", | ||
| "value": -6.554929, | ||
| "vote": -2 | ||
| }, | ||
| { | ||
| "symbol": "BTCUSD", | ||
| "feature": "ret20d_z", | ||
| "value": -6.478535, | ||
| "vote": -2 | ||
| } | ||
| ], | ||
| "missing": [ | ||
| "EEM/EFA", | ||
| "SMH/SPY" | ||
| ] | ||
| }, | ||
| "credit": { | ||
| "inputs": [ | ||
| { | ||
| "symbol": "HYG/IEF", | ||
| "feature": "ret20d_z", | ||
| "value": -6.352958, | ||
| "vote": -2 | ||
| } | ||
| ], | ||
| "missing": [ | ||
| "LQD/IEF", | ||
| "XLF/SPY" | ||
| ] | ||
| }, | ||
| "volatility": { | ||
| "inputs": [ | ||
| { | ||
| "symbol": "SPY", | ||
| "feature": "rvol20_pctile_1y", | ||
| "value": 0.932806, | ||
| "vote": -2 | ||
| } | ||
| ], | ||
| "missing": [ | ||
| "VIX" | ||
| ] | ||
| } | ||
| }, | ||
| "classification": "stress", | ||
| "confidence": 0.8, | ||
| "would_change": [ | ||
| "robust: no single-pillar one-notch change alters this classification" | ||
| ], | ||
| "notes": [ | ||
| "HYG: HYG/IEF ratio proxies HY OAS direction intraday; true OAS (daily, lagged) comes from FRED BAMLH0A0HYM2 in the regime layer.", | ||
| "RSP: Equal-weight ratio proxies constituent breadth; true %>200dma needs constituent data (licensing-gated).", | ||
| "UUP: DXY proxy; futures DX licensing-gated at this tier.", | ||
| "degraded: missing inputs ACWI, EEM/EFA, LQD/IEF, QQQ, SMH/SPY, VIX, XLF/SPY" | ||
| ] | ||
| }, | ||
| "digest": "f469ddc93ce803456bf6eed265c1ffa94690bb3ed972e9dff95c7dab52972e94", | ||
| "signature": "0f93202e78e81ea84ba36e2c2e787ef493aba32c61140a948553f314a3403f042220ac64cf04e8d22e9044b2e6e9712587b1e023cb0dd796353543609aec380a", | ||
| "verification_key": "e324c4149db27151f65df25d528d3037d3b3bc4165b0d28c0769369cba958ae3" | ||
| }, | ||
| { | ||
| "payload": { | ||
| "schema": "scopeblind.macro.tape-snapshot/1", | ||
| "engine_version": "macro-engine/0.1.0", | ||
| "as_of": "2025-08-23", | ||
| "session": "daily_close", | ||
| "tape_type": "mixed", | ||
| "coherence": 0, | ||
| "material": true, | ||
| "signals": [ | ||
| { | ||
| "id": "breadth_rel", | ||
| "z": 0 | ||
| }, | ||
| { | ||
| "id": "credit", | ||
| "z": -2.631816 | ||
| }, | ||
| { | ||
| "id": "dollar", | ||
| "z": 3.61392 | ||
| }, | ||
| { | ||
| "id": "duration", | ||
| "z": 3.20995 | ||
| }, | ||
| { | ||
| "id": "equity", | ||
| "z": -3.860863 | ||
| }, | ||
| { | ||
| "id": "high_beta", | ||
| "z": -4.467346 | ||
| }, | ||
| { | ||
| "id": "smallcap_rel", | ||
| "z": 0 | ||
| } | ||
| ], | ||
| "unavailable": [ | ||
| "banks_rel", | ||
| "copper", | ||
| "gold", | ||
| "growth_beta", | ||
| "oil" | ||
| ], | ||
| "untestable_types": [ | ||
| "inflation_shock" | ||
| ], | ||
| "attribution": { | ||
| "tier": "unknown", | ||
| "detail": "material moves without a coherent cross-asset pattern or scheduled catalyst", | ||
| "events": [] | ||
| }, | ||
| "inputs_digest": "c202156a133cdee47311f843fbc18082311ca653dd4293496ea1c47346fa68c7", | ||
| "would_change": [ | ||
| "a coherent cross-asset pattern forming would move this off mixed" | ||
| ], | ||
| "notes": [ | ||
| "degraded: unavailable signals banks_rel, copper, gold, growth_beta, oil", | ||
| "untestable tape types with current data: inflation_shock", | ||
| "session basis: daily close-to-close; intraday windows plug into the same rules when a live feed is configured" | ||
| ] | ||
| }, | ||
| "digest": "43e98d770cb3b4e47c915164d7fe0aa7ec257968978a5cd1aa914e9e2403decd", | ||
| "signature": "f7cf06b796186a7a6b76053f30b5dd3b96affb6b3853df78550719576d0059bc4a36d34c74afd3d122770e34cbdda03e3f0adcd794c314396a411d3ee5f2590f", | ||
| "verification_key": "e324c4149db27151f65df25d528d3037d3b3bc4165b0d28c0769369cba958ae3" | ||
| }, | ||
| { | ||
| "payload": { | ||
| "schema": "scopeblind.macro.regime-snapshot/1", | ||
| "engine_version": "macro-engine/0.1.0", | ||
| "as_of": "2026-05-01", | ||
| "pillars": { | ||
| "growth": -2, | ||
| "inflation": 2, | ||
| "liquidity": -2, | ||
| "policy_freedom": -2, | ||
| "credit_conditions": -2 | ||
| }, | ||
| "inputs": [ | ||
| { | ||
| "series_id": "ICSA", | ||
| "transform": "diff13w", | ||
| "value": 35123.671753, | ||
| "z": -6.764746, | ||
| "vote": -2, | ||
| "asof_digest": "73fa36d5ba21ff19e82757760ffc56899c4d33ca617cbdf79523e2eb1550f573", | ||
| "latest_vintage": "2026-04-29" | ||
| }, | ||
| { | ||
| "series_id": "PAYEMS", | ||
| "transform": "mom3_ann", | ||
| "value": -0.036344, | ||
| "z": -3.501479, | ||
| "vote": -2, | ||
| "asof_digest": "64ca109e3b7d52348a813e5c7fa3695ce0801b30a9ac8cc2aa7cc248e6292abf", | ||
| "latest_vintage": "2026-04-08" | ||
| }, | ||
| { | ||
| "series_id": "INDPRO", | ||
| "transform": "mom6_ann", | ||
| "value": -0.036233, | ||
| "z": -4.775509, | ||
| "vote": -2, | ||
| "asof_digest": "880c1c9898d45bca6a5d9803d3a687e7702ada5b4dee082d68e685fb8540be82", | ||
| "latest_vintage": "2026-04-17" | ||
| }, | ||
| { | ||
| "series_id": "RSXFS", | ||
| "transform": "mom3_ann", | ||
| "value": -0.036344, | ||
| "z": -3.485408, | ||
| "vote": -2, | ||
| "asof_digest": "87bb798c4e9efdfb41f31a11cc3f49f96fe9db48d753cd5aaa0643f3b4a59e1d", | ||
| "latest_vintage": "2026-04-17" | ||
| }, | ||
| { | ||
| "series_id": "USALOLITONOSTSAM", | ||
| "transform": "diff6m", | ||
| "value": -1.152544, | ||
| "z": -5.744365, | ||
| "vote": -2, | ||
| "asof_digest": "4e7ae6048b479286b801201eebf6db1d28ada976b1841d8b41cb5d95c99d9929", | ||
| "latest_vintage": "2026-04-10" | ||
| }, | ||
| { | ||
| "series_id": "CPILFESL", | ||
| "transform": "mom3_ann", | ||
| "value": 0.069551, | ||
| "z": 3.342129, | ||
| "vote": 2, | ||
| "asof_digest": "b0a91055ce6a6a879010796266ef9236bb911a888a54ce6af7b24e6010183ca3", | ||
| "latest_vintage": "2026-04-14" | ||
| }, | ||
| { | ||
| "series_id": "PCEPILFE", | ||
| "transform": "mom3_ann", | ||
| "value": 0.067001, | ||
| "z": 3.342148, | ||
| "vote": 2, | ||
| "asof_digest": "b53a27bb365e01fa1e2ac25f446930ff6f852c4ed2a6fd2eefff38cc96c9da57", | ||
| "latest_vintage": "2026-04-29" | ||
| }, | ||
| { | ||
| "series_id": "T10YIE", | ||
| "transform": "diff3m", | ||
| "value": 0.153311, | ||
| "z": 5.226471, | ||
| "vote": 2, | ||
| "asof_digest": "b839addb59d3c4a84a0a4b27e6ad47636b83a3729b1cc7144bea8dd58db2ff73", | ||
| "latest_vintage": "2026-05-01" | ||
| }, | ||
| { | ||
| "series_id": "DCOILWTICO", | ||
| "transform": "mom3_ann", | ||
| "value": 0.653636, | ||
| "z": 5.382054, | ||
| "vote": 2, | ||
| "asof_digest": "97178dd3c33fa82c7479b66c6132a7d8d96e47ccc39ffc4ba93d64cc38ab65b8", | ||
| "latest_vintage": "2026-05-01" | ||
| }, | ||
| { | ||
| "series_id": "AHETPI", | ||
| "transform": "mom3_ann", | ||
| "value": 0.067001, | ||
| "z": 3.3094, | ||
| "vote": 2, | ||
| "asof_digest": "783bab21a0e6c7c761d4c62ceab7c582cefa6d050b2e03898a4a62f80971ef90", | ||
| "latest_vintage": "2026-04-08" | ||
| }, | ||
| { | ||
| "series_id": "WALCL", | ||
| "transform": "diff13w", | ||
| "value": -355139.244176, | ||
| "z": -6.539508, | ||
| "vote": -2, | ||
| "asof_digest": "af2dd3799a152fdb8171b203aec25bd0b0e276e87df7be9bdc8a989f48a51346", | ||
| "latest_vintage": "2026-04-25" | ||
| }, | ||
| { | ||
| "series_id": "WTREGEN", | ||
| "transform": "diff13w", | ||
| "value": 98346.28091, | ||
| "z": -6.764746, | ||
| "vote": -2, | ||
| "asof_digest": "0e493e335271dcbeeae69f95d4683b35988eba5248e525fed6151b840f127989", | ||
| "latest_vintage": "2026-04-25" | ||
| }, | ||
| { | ||
| "series_id": "RRPONTSYD", | ||
| "transform": "diff13w", | ||
| "value": 158.625566, | ||
| "z": -5.60151, | ||
| "vote": -2, | ||
| "asof_digest": "62bd49d946bd279358ff87d32d370fa6d0a68c37682b1ba7ad99e3bfd1687daa", | ||
| "latest_vintage": "2026-05-01" | ||
| }, | ||
| { | ||
| "series_id": "M2SL", | ||
| "transform": "mom6_ann", | ||
| "value": -0.036233, | ||
| "z": -4.775509, | ||
| "vote": -2, | ||
| "asof_digest": "8b96e538db1b6659919cb95408ff3d5ee407783a03e7f26e0920aa50123e9a19", | ||
| "latest_vintage": "2026-04-27" | ||
| }, | ||
| { | ||
| "series_id": "DTWEXBGS", | ||
| "transform": "diff3m", | ||
| "value": 7.998853, | ||
| "z": -5.226471, | ||
| "vote": -2, | ||
| "asof_digest": "05d20fa0c9d7d5f2dff3d84ec7ff62579e7eaea87ff66bb50a2a8e869165de5d", | ||
| "latest_vintage": "2026-05-01" | ||
| }, | ||
| { | ||
| "series_id": "DFEDTARU", | ||
| "transform": "level", | ||
| "value": 0.999608, | ||
| "z": null, | ||
| "vote": null, | ||
| "asof_digest": "3922373305be0976cddffb6778d61343e9289d3f6d3fe8039a2ca95a765c735f", | ||
| "latest_vintage": "2026-05-01" | ||
| }, | ||
| { | ||
| "series_id": "DFII10", | ||
| "transform": "diff3m", | ||
| "value": 0.119983, | ||
| "z": -5.226471, | ||
| "vote": -2, | ||
| "asof_digest": "474a4dd5f297eb398235b01334b7fc283979b30c96c1b1ac57351eedc6064cdf", | ||
| "latest_vintage": "2026-05-01" | ||
| }, | ||
| { | ||
| "series_id": "T5YIFR", | ||
| "transform": "level", | ||
| "value": 3.201024, | ||
| "z": null, | ||
| "vote": -2, | ||
| "asof_digest": "b8e076fbcd90990be22e3f6fc53461a74d5f0a4553cb0152b70c4fea50f4530f", | ||
| "latest_vintage": "2026-05-01" | ||
| }, | ||
| { | ||
| "series_id": "BAMLH0A0HYM2", | ||
| "transform": "diff13w", | ||
| "value": 1.110379, | ||
| "z": -5.60151, | ||
| "vote": -2, | ||
| "asof_digest": "8ec3d58658b36d414849b002fef15350817a81ddcb81e9e203a9ada8bd8d1c3f", | ||
| "latest_vintage": "2026-05-01" | ||
| }, | ||
| { | ||
| "series_id": "BAMLC0A0CM", | ||
| "transform": "diff13w", | ||
| "value": 0.073323, | ||
| "z": -5.226471, | ||
| "vote": -2, | ||
| "asof_digest": "370a4e107534d46a1a542d1e16ce9f9f4a17aeeb377f9e5c4481f1e0f3a2facb", | ||
| "latest_vintage": "2026-05-01" | ||
| }, | ||
| { | ||
| "series_id": "NFCI", | ||
| "transform": "level", | ||
| "value": 0.550016, | ||
| "z": null, | ||
| "vote": -2, | ||
| "asof_digest": "f0f61455659b69270627f8c5cf767fc2f5dc72985c36d682edad857629df4d9d", | ||
| "latest_vintage": "2026-04-29" | ||
| }, | ||
| { | ||
| "series_id": "NET_LIQUIDITY", | ||
| "transform": "diff13w", | ||
| "value": -650.297517, | ||
| "z": -6.328823, | ||
| "vote": -2, | ||
| "asof_digest": "1e43b543c63afcf7659de4c5ddaf99ba96dc4d1c7f55052aa392b0aadd1207a9", | ||
| "latest_vintage": "2026-04-25" | ||
| }, | ||
| { | ||
| "series_id": "REAL_POLICY_RATE", | ||
| "transform": "level", | ||
| "value": -3.905295, | ||
| "z": null, | ||
| "vote": -2, | ||
| "asof_digest": "9427fbf69d076b6f3a9536742e8d4f2ac15c4e4085075a8b60273d34970b0f4d", | ||
| "latest_vintage": "2026-05-01" | ||
| } | ||
| ], | ||
| "vintage_digest": "77304f7e1203df3211824c78c7c9e800a0dd52b744f5173324524174ad96d3bc", | ||
| "candidate_regime": "stagflation", | ||
| "regime": "stagflation", | ||
| "liquidity_overlay": "contraction", | ||
| "hysteresis": { | ||
| "current": "stagflation", | ||
| "candidate": null, | ||
| "candidate_weeks": 0, | ||
| "weeks_since_switch": 999 | ||
| }, | ||
| "transition": null, | ||
| "playbook": { | ||
| "prefer": [ | ||
| "energy", | ||
| "gold", | ||
| "dollar", | ||
| "defensives", | ||
| "cash" | ||
| ], | ||
| "avoid": [ | ||
| "unhedged cyclicals", | ||
| "levered credit" | ||
| ], | ||
| "confidence": "medium" | ||
| }, | ||
| "confidence": 0.956522, | ||
| "would_change": [ | ||
| "robust: no single-pillar one-notch change alters this candidate regime" | ||
| ], | ||
| "notes": [ | ||
| "degraded: no usable vote from DFEDTARU", | ||
| "Net liquidity (WALCL - TGA - RRP) validity is regime-dependent: strongest when reserve scarcity binds.", | ||
| "PMI family is licensing-gated: growth diffusion uses claims, payrolls, production, retail, and OECD CLI." | ||
| ] | ||
| }, | ||
| "digest": "27a4b9dd293ae77487e919adbf428e4911da62907c39dd017c50376ad9e77591", | ||
| "signature": "806c531043618f2c4da47180ace2020def35fbf12bbf603061e4fb3264b97940c2bcfd50e395d16c8f2e45bdbe2ff69a84c00ddbe330fcaa29bc95296da66207", | ||
| "verification_key": "e324c4149db27151f65df25d528d3037d3b3bc4165b0d28c0769369cba958ae3" | ||
| }, | ||
| { | ||
| "payload": { | ||
| "schema": "scopeblind.macro.vulnerability/1", | ||
| "engine_version": "macro-engine/0.1.0", | ||
| "as_of": "2025-08-23", | ||
| "posture": { | ||
| "regime": "stagflation", | ||
| "market_state": "stress", | ||
| "refs": [ | ||
| { | ||
| "schema": "scopeblind.macro.market-state/1", | ||
| "as_of": "2025-08-23", | ||
| "digest": "f469ddc93ce803456bf6eed265c1ffa94690bb3ed972e9dff95c7dab52972e94" | ||
| }, | ||
| { | ||
| "schema": "scopeblind.macro.tape-snapshot/1", | ||
| "as_of": "2025-08-23", | ||
| "digest": "43e98d770cb3b4e47c915164d7fe0aa7ec257968978a5cd1aa914e9e2403decd" | ||
| }, | ||
| { | ||
| "schema": "scopeblind.macro.regime-snapshot/1", | ||
| "as_of": "2026-05-01", | ||
| "digest": "27a4b9dd293ae77487e919adbf428e4911da62907c39dd017c50376ad9e77591" | ||
| } | ||
| ] | ||
| }, | ||
| "factor_exposures": [ | ||
| { | ||
| "factor": "equity_beta", | ||
| "net": 0.55, | ||
| "gross": 0.55, | ||
| "contributors": [ | ||
| { | ||
| "symbol": "SPY", | ||
| "weight": 0.45 | ||
| }, | ||
| { | ||
| "symbol": "EEM", | ||
| "weight": 0.1 | ||
| } | ||
| ] | ||
| }, | ||
| { | ||
| "factor": "duration", | ||
| "net": 0.3, | ||
| "gross": 0.3, | ||
| "contributors": [ | ||
| { | ||
| "symbol": "IEF", | ||
| "weight": 0.3 | ||
| } | ||
| ] | ||
| }, | ||
| { | ||
| "factor": "credit", | ||
| "net": 0.15, | ||
| "gross": 0.15, | ||
| "contributors": [ | ||
| { | ||
| "symbol": "HYG", | ||
| "weight": 0.15 | ||
| } | ||
| ] | ||
| }, | ||
| { | ||
| "factor": "usd_sensitivity", | ||
| "net": 0.1, | ||
| "gross": 0.1, | ||
| "contributors": [ | ||
| { | ||
| "symbol": "EEM", | ||
| "weight": 0.1 | ||
| } | ||
| ] | ||
| }, | ||
| { | ||
| "factor": "global", | ||
| "net": 0.1, | ||
| "gross": 0.1, | ||
| "contributors": [ | ||
| { | ||
| "symbol": "EEM", | ||
| "weight": 0.1 | ||
| } | ||
| ] | ||
| } | ||
| ], | ||
| "betas": [ | ||
| { | ||
| "factor": "equity_beta", | ||
| "proxy": "SPY", | ||
| "beta": 0.562937, | ||
| "r2": 0.926912, | ||
| "n": 120 | ||
| }, | ||
| { | ||
| "factor": "duration", | ||
| "proxy": "IEF", | ||
| "beta": -1.916486, | ||
| "r2": 0.368425, | ||
| "n": 120 | ||
| }, | ||
| { | ||
| "factor": "credit", | ||
| "proxy": "HYG", | ||
| "beta": 0.344651, | ||
| "r2": 0.727716, | ||
| "n": 120 | ||
| }, | ||
| { | ||
| "factor": "usd", | ||
| "proxy": "UUP", | ||
| "beta": -1.562837, | ||
| "r2": 0.635209, | ||
| "n": 120 | ||
| }, | ||
| { | ||
| "factor": "liquidity_beta", | ||
| "proxy": "BTCUSD", | ||
| "beta": 0.318435, | ||
| "r2": 0.917171, | ||
| "n": 120 | ||
| }, | ||
| { | ||
| "factor": "commodity", | ||
| "proxy": "DBC", | ||
| "beta": null, | ||
| "r2": null, | ||
| "n": 0, | ||
| "note": "proxy DBC unavailable or short" | ||
| }, | ||
| { | ||
| "factor": "real_assets", | ||
| "proxy": "GLD", | ||
| "beta": null, | ||
| "r2": null, | ||
| "n": 0, | ||
| "note": "proxy GLD unavailable or short" | ||
| } | ||
| ], | ||
| "vulnerabilities": [ | ||
| { | ||
| "factor": "equity_beta", | ||
| "exposure": 0.55, | ||
| "scenario_sensitivity": -2, | ||
| "pain": 1.1, | ||
| "note": "net long equity_beta; scenario expects equity_beta down (-2)" | ||
| }, | ||
| { | ||
| "factor": "duration", | ||
| "exposure": 0.3, | ||
| "scenario_sensitivity": -1, | ||
| "pain": 0.3, | ||
| "note": "net long duration; scenario expects duration down (-1)" | ||
| }, | ||
| { | ||
| "factor": "credit", | ||
| "exposure": 0.15, | ||
| "scenario_sensitivity": -2, | ||
| "pain": 0.3, | ||
| "note": "net long credit; scenario expects credit down (-2)" | ||
| } | ||
| ], | ||
| "inputs_digest": "10f7e77fbbec4240ac6e203bf443a6ee03fb19184dceec2950774c066796f8c6", | ||
| "would_change": [ | ||
| "reducing net equity_beta exposure toward zero removes the top vulnerability", | ||
| "a regime or market-state transition reselects the scenario stress vector" | ||
| ], | ||
| "notes": [ | ||
| "decision aid with stated scenario priors; not a covariance risk model and not a VaR claim" | ||
| ] | ||
| }, | ||
| "digest": "d277793511a0dcda199889d8beedbe849146cf0fc4cb216ce1dc77cadf20b237", | ||
| "signature": "416c38e1ed67583d2cff5b8c4020accd9d550eb49dde5956af750f4dae5b0d0ca994f564fe3db7b4a40aaab062b6236f0f8962ee5a4f4d9d00fde5400ef64506", | ||
| "verification_key": "e324c4149db27151f65df25d528d3037d3b3bc4165b0d28c0769369cba958ae3" | ||
| } | ||
| ], | ||
| "journal": [ | ||
| { | ||
| "payload": { | ||
| "schema": "scopeblind.macro.journal-entry/1", | ||
| "engine_version": "macro-engine/0.1.0", | ||
| "as_of": "2025-08-23", | ||
| "author": "demo-model", | ||
| "note": "Regime read stagflation while the daily tape printed stress. Cut gross and lengthened nothing; the book is long the wrong factors here.", | ||
| "references": [ | ||
| { | ||
| "schema": "scopeblind.macro.market-state/1", | ||
| "as_of": "2025-08-23", | ||
| "digest": "f469ddc93ce803456bf6eed265c1ffa94690bb3ed972e9dff95c7dab52972e94" | ||
| }, | ||
| { | ||
| "schema": "scopeblind.macro.vulnerability/1", | ||
| "as_of": "2025-08-23", | ||
| "digest": "d277793511a0dcda199889d8beedbe849146cf0fc4cb216ce1dc77cadf20b237" | ||
| }, | ||
| { | ||
| "schema": "scopeblind.macro.regime-snapshot/1", | ||
| "as_of": "2026-05-01", | ||
| "digest": "27a4b9dd293ae77487e919adbf428e4911da62907c39dd017c50376ad9e77591" | ||
| } | ||
| ], | ||
| "tags": [ | ||
| "risk-off", | ||
| "stagflation", | ||
| "vulnerability" | ||
| ] | ||
| }, | ||
| "digest": "95415964066eaf10e5de3e49dd18763c8c7d79099baa0bbe670b402f52243923", | ||
| "signature": "adce443ba5de6d2e2edea9b5cc19744ae0a7842ad552860c03e895e5393434f4f0448bb5eb5d81f2aae7cbf222494931bfcab30ad465931f073ca2350e042b0c", | ||
| "verification_key": "e324c4149db27151f65df25d528d3037d3b3bc4165b0d28c0769369cba958ae3" | ||
| } | ||
| ], | ||
| "manifest": { | ||
| "payload": { | ||
| "schema": "scopeblind.macro.track-record-manifest/1", | ||
| "engine_version": "macro-engine/0.1.0", | ||
| "exported_at": "2026-05-01T00:00:00Z", | ||
| "model_verification_key": "e324c4149db27151f65df25d528d3037d3b3bc4165b0d28c0769369cba958ae3", | ||
| "period": { | ||
| "from": "2025-01-02", | ||
| "to": "2026-05-01" | ||
| }, | ||
| "entries": [ | ||
| { | ||
| "schema": "scopeblind.macro.market-state/1", | ||
| "as_of": "2025-08-23", | ||
| "digest": "f469ddc93ce803456bf6eed265c1ffa94690bb3ed972e9dff95c7dab52972e94" | ||
| }, | ||
| { | ||
| "schema": "scopeblind.macro.tape-snapshot/1", | ||
| "as_of": "2025-08-23", | ||
| "digest": "43e98d770cb3b4e47c915164d7fe0aa7ec257968978a5cd1aa914e9e2403decd" | ||
| }, | ||
| { | ||
| "schema": "scopeblind.macro.regime-snapshot/1", | ||
| "as_of": "2026-05-01", | ||
| "digest": "27a4b9dd293ae77487e919adbf428e4911da62907c39dd017c50376ad9e77591" | ||
| }, | ||
| { | ||
| "schema": "scopeblind.macro.vulnerability/1", | ||
| "as_of": "2025-08-23", | ||
| "digest": "d277793511a0dcda199889d8beedbe849146cf0fc4cb216ce1dc77cadf20b237" | ||
| }, | ||
| { | ||
| "schema": "scopeblind.macro.journal-entry/1", | ||
| "as_of": "2025-08-23", | ||
| "digest": "95415964066eaf10e5de3e49dd18763c8c7d79099baa0bbe670b402f52243923" | ||
| } | ||
| ], | ||
| "snapshot_count": 4, | ||
| "journal_count": 1, | ||
| "history_head_digest": "15fdd1acd962b562e05b73ca37b9ce3cce064b072fee5b27528edc9cd61eb62f", | ||
| "sequence": 1, | ||
| "previous_manifest_digest": null, | ||
| "previous_history_head_digest": null | ||
| }, | ||
| "digest": "e5697c073e213bc94b2e73a53a2bd140f80821b905daf4e547a9c5674f29643f", | ||
| "signature": "c7356a7845c73f0c055144c9e784c8b77826ed85cd6e6271acf2a2c5e4b60be8131bb78a0a914e58de57bc9c0294753700b5ae1f5d72ddf4af494a7a2b8e2e0f", | ||
| "verification_key": "e324c4149db27151f65df25d528d3037d3b3bc4165b0d28c0769369cba958ae3" | ||
| }, | ||
| "prior_manifests": [], | ||
| "anchor_chain": [ | ||
| { | ||
| "payload": { | ||
| "schema": "scopeblind.macro.track-record-anchor/1", | ||
| "engine_version": "macro-engine/0.1.0", | ||
| "anchored_at": "2026-05-01T00:00:00Z", | ||
| "sequence": 1, | ||
| "manifest_digest": "e5697c073e213bc94b2e73a53a2bd140f80821b905daf4e547a9c5674f29643f", | ||
| "history_head_digest": "15fdd1acd962b562e05b73ca37b9ce3cce064b072fee5b27528edc9cd61eb62f", | ||
| "previous_anchor_digest": null | ||
| }, | ||
| "digest": "062188f17b7d0ef9d1acb11e4e85b4ab8c61abb5c192c4ce0d7a784bbf7fb07c", | ||
| "signature": "b41012fe8a60cf65da5d28368251994e6d51ab81b81427078297aab11aa74f4dcdc163959cf14d742bb7a67a5c1ccccb332cde49a701d570535af2691a4a6d09", | ||
| "verification_key": "e324c4149db27151f65df25d528d3037d3b3bc4165b0d28c0769369cba958ae3" | ||
| } | ||
| ], | ||
| "transparency": { | ||
| "head": { | ||
| "payload": { | ||
| "schema": "scopeblind.macro.transparency-head/1", | ||
| "engine_version": "macro-engine/0.1.0", | ||
| "log_id": "scopeblind.macro.demo-log", | ||
| "tree_size": 5, | ||
| "root_hash": "40efcc8314749f9b68e2d9555e16d6d223dba67069434108808e18249eb46c3c", | ||
| "timestamp": "2026-05-01T00:00:00Z", | ||
| "previous_root_hash": null | ||
| }, | ||
| "digest": "6fd187a85cb09a7e01f383e6dee761801b044c04e3c518f00473db4edd626e78", | ||
| "signature": "629823481769449e85c793ccd06cfe819b60192ae0a0e72f62b6108b3082b66e8fab5f86c7ae738f599b8ec0a079747ecf308b9540168d8fdc59afbc75dfd409", | ||
| "verification_key": "e324c4149db27151f65df25d528d3037d3b3bc4165b0d28c0769369cba958ae3" | ||
| }, | ||
| "witness": { | ||
| "payload": { | ||
| "schema": "scopeblind.macro.transparency-witness/1", | ||
| "engine_version": "macro-engine/0.1.0", | ||
| "head_digest": "6fd187a85cb09a7e01f383e6dee761801b044c04e3c518f00473db4edd626e78", | ||
| "root_hash": "40efcc8314749f9b68e2d9555e16d6d223dba67069434108808e18249eb46c3c", | ||
| "tree_size": 5, | ||
| "witnessed_at": "2026-05-01T00:00:00Z", | ||
| "note": "demo transparency witness (independent dev key)" | ||
| }, | ||
| "digest": "dd0978d2a1aaefa180e346f8b28d64f31c3bdf7f90b3d2c1f632e45c05cac911", | ||
| "signature": "87348b1ac8cc85a280cac613489696ece8a6c668d42cb87d5799930b96978aa39ea4598419425b664365a97c8c0c9618b1ab534506264755d6c108178e4c1002", | ||
| "verification_key": "f65f4bc4a3a2d5dc899cb8d99682cd8492789c2d73e5f9e939caa3adc875c322" | ||
| }, | ||
| "inclusions": [ | ||
| { | ||
| "digest": "f469ddc93ce803456bf6eed265c1ffa94690bb3ed972e9dff95c7dab52972e94", | ||
| "proof": { | ||
| "leaf_index": 0, | ||
| "tree_size": 5, | ||
| "audit_path": [ | ||
| "5ed6de6f809567a968c710ba86d0737c2f811bcabcddc437a8cfa53f83ffe09f", | ||
| "888f0215bbdc34f19b01cfbe9010e5883d2404aec1b1b17fb28c9798f46895df", | ||
| "d043a0e988ecdfe94fb63804f73d51365e514243e9a248e2ab16b317ce0b3f8d" | ||
| ] | ||
| } | ||
| }, | ||
| { | ||
| "digest": "43e98d770cb3b4e47c915164d7fe0aa7ec257968978a5cd1aa914e9e2403decd", | ||
| "proof": { | ||
| "leaf_index": 1, | ||
| "tree_size": 5, | ||
| "audit_path": [ | ||
| "74d3d8019a6944bbfb0324a1bf9a54e2bf61fb7c46cb43f700df1f158894b5e6", | ||
| "888f0215bbdc34f19b01cfbe9010e5883d2404aec1b1b17fb28c9798f46895df", | ||
| "d043a0e988ecdfe94fb63804f73d51365e514243e9a248e2ab16b317ce0b3f8d" | ||
| ] | ||
| } | ||
| }, | ||
| { | ||
| "digest": "27a4b9dd293ae77487e919adbf428e4911da62907c39dd017c50376ad9e77591", | ||
| "proof": { | ||
| "leaf_index": 2, | ||
| "tree_size": 5, | ||
| "audit_path": [ | ||
| "a5947d673a413432359ca27bb4d2903f119fa05a8770bb3bae40c90f0e019038", | ||
| "7c8ed8da587a98ec9a30df018b41bd793f33a938459fdfa96c549abcd0f05f85", | ||
| "d043a0e988ecdfe94fb63804f73d51365e514243e9a248e2ab16b317ce0b3f8d" | ||
| ] | ||
| } | ||
| }, | ||
| { | ||
| "digest": "d277793511a0dcda199889d8beedbe849146cf0fc4cb216ce1dc77cadf20b237", | ||
| "proof": { | ||
| "leaf_index": 3, | ||
| "tree_size": 5, | ||
| "audit_path": [ | ||
| "31f0efb1db7235d4c11de485b83bbb9a9989a38069b24f443835a73b118fca22", | ||
| "7c8ed8da587a98ec9a30df018b41bd793f33a938459fdfa96c549abcd0f05f85", | ||
| "d043a0e988ecdfe94fb63804f73d51365e514243e9a248e2ab16b317ce0b3f8d" | ||
| ] | ||
| } | ||
| }, | ||
| { | ||
| "digest": "95415964066eaf10e5de3e49dd18763c8c7d79099baa0bbe670b402f52243923", | ||
| "proof": { | ||
| "leaf_index": 4, | ||
| "tree_size": 5, | ||
| "audit_path": [ | ||
| "43feda61a2b32f67b92add7d712447aa9ddcc96af2bda2553b5cca926cfdc897" | ||
| ] | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| } |
| { | ||
| "payload": { | ||
| "schema": "scopeblind.macro.transparency-head/1", | ||
| "engine_version": "macro-engine/0.1.0", | ||
| "log_id": "scopeblind.macro.demo-log", | ||
| "tree_size": 5, | ||
| "root_hash": "40efcc8314749f9b68e2d9555e16d6d223dba67069434108808e18249eb46c3c", | ||
| "timestamp": "2026-05-01T00:00:00Z", | ||
| "previous_root_hash": null | ||
| }, | ||
| "digest": "6fd187a85cb09a7e01f383e6dee761801b044c04e3c518f00473db4edd626e78", | ||
| "signature": "629823481769449e85c793ccd06cfe819b60192ae0a0e72f62b6108b3082b66e8fab5f86c7ae738f599b8ec0a079747ecf308b9540168d8fdc59afbc75dfd409", | ||
| "verification_key": "e324c4149db27151f65df25d528d3037d3b3bc4165b0d28c0769369cba958ae3" | ||
| } |
| { | ||
| "payload": { | ||
| "schema": "scopeblind.macro.transparency-witness/1", | ||
| "engine_version": "macro-engine/0.1.0", | ||
| "head_digest": "6fd187a85cb09a7e01f383e6dee761801b044c04e3c518f00473db4edd626e78", | ||
| "root_hash": "40efcc8314749f9b68e2d9555e16d6d223dba67069434108808e18249eb46c3c", | ||
| "tree_size": 5, | ||
| "witnessed_at": "2026-05-01T00:00:00Z", | ||
| "note": "demo transparency witness (independent dev key)" | ||
| }, | ||
| "digest": "dd0978d2a1aaefa180e346f8b28d64f31c3bdf7f90b3d2c1f632e45c05cac911", | ||
| "signature": "87348b1ac8cc85a280cac613489696ece8a6c668d42cb87d5799930b96978aa39ea4598419425b664365a97c8c0c9618b1ab534506264755d6c108178e4c1002", | ||
| "verification_key": "f65f4bc4a3a2d5dc899cb8d99682cd8492789c2d73e5f9e939caa3adc875c322" | ||
| } |
| { | ||
| "payload": { | ||
| "schema": "scopeblind.macro.vulnerability/1", | ||
| "engine_version": "macro-engine/0.1.0", | ||
| "as_of": "2025-08-23", | ||
| "posture": { | ||
| "regime": "stagflation", | ||
| "market_state": "stress", | ||
| "refs": [ | ||
| { | ||
| "schema": "scopeblind.macro.market-state/1", | ||
| "as_of": "2025-08-23", | ||
| "digest": "f469ddc93ce803456bf6eed265c1ffa94690bb3ed972e9dff95c7dab52972e94" | ||
| }, | ||
| { | ||
| "schema": "scopeblind.macro.tape-snapshot/1", | ||
| "as_of": "2025-08-23", | ||
| "digest": "43e98d770cb3b4e47c915164d7fe0aa7ec257968978a5cd1aa914e9e2403decd" | ||
| }, | ||
| { | ||
| "schema": "scopeblind.macro.regime-snapshot/1", | ||
| "as_of": "2026-05-01", | ||
| "digest": "27a4b9dd293ae77487e919adbf428e4911da62907c39dd017c50376ad9e77591" | ||
| } | ||
| ] | ||
| }, | ||
| "factor_exposures": [ | ||
| { | ||
| "factor": "equity_beta", | ||
| "net": 0.55, | ||
| "gross": 0.55, | ||
| "contributors": [ | ||
| { | ||
| "symbol": "SPY", | ||
| "weight": 0.45 | ||
| }, | ||
| { | ||
| "symbol": "EEM", | ||
| "weight": 0.1 | ||
| } | ||
| ] | ||
| }, | ||
| { | ||
| "factor": "duration", | ||
| "net": 0.3, | ||
| "gross": 0.3, | ||
| "contributors": [ | ||
| { | ||
| "symbol": "IEF", | ||
| "weight": 0.3 | ||
| } | ||
| ] | ||
| }, | ||
| { | ||
| "factor": "credit", | ||
| "net": 0.15, | ||
| "gross": 0.15, | ||
| "contributors": [ | ||
| { | ||
| "symbol": "HYG", | ||
| "weight": 0.15 | ||
| } | ||
| ] | ||
| }, | ||
| { | ||
| "factor": "usd_sensitivity", | ||
| "net": 0.1, | ||
| "gross": 0.1, | ||
| "contributors": [ | ||
| { | ||
| "symbol": "EEM", | ||
| "weight": 0.1 | ||
| } | ||
| ] | ||
| }, | ||
| { | ||
| "factor": "global", | ||
| "net": 0.1, | ||
| "gross": 0.1, | ||
| "contributors": [ | ||
| { | ||
| "symbol": "EEM", | ||
| "weight": 0.1 | ||
| } | ||
| ] | ||
| } | ||
| ], | ||
| "betas": [ | ||
| { | ||
| "factor": "equity_beta", | ||
| "proxy": "SPY", | ||
| "beta": 0.562937, | ||
| "r2": 0.926912, | ||
| "n": 120 | ||
| }, | ||
| { | ||
| "factor": "duration", | ||
| "proxy": "IEF", | ||
| "beta": -1.916486, | ||
| "r2": 0.368425, | ||
| "n": 120 | ||
| }, | ||
| { | ||
| "factor": "credit", | ||
| "proxy": "HYG", | ||
| "beta": 0.344651, | ||
| "r2": 0.727716, | ||
| "n": 120 | ||
| }, | ||
| { | ||
| "factor": "usd", | ||
| "proxy": "UUP", | ||
| "beta": -1.562837, | ||
| "r2": 0.635209, | ||
| "n": 120 | ||
| }, | ||
| { | ||
| "factor": "liquidity_beta", | ||
| "proxy": "BTCUSD", | ||
| "beta": 0.318435, | ||
| "r2": 0.917171, | ||
| "n": 120 | ||
| }, | ||
| { | ||
| "factor": "commodity", | ||
| "proxy": "DBC", | ||
| "beta": null, | ||
| "r2": null, | ||
| "n": 0, | ||
| "note": "proxy DBC unavailable or short" | ||
| }, | ||
| { | ||
| "factor": "real_assets", | ||
| "proxy": "GLD", | ||
| "beta": null, | ||
| "r2": null, | ||
| "n": 0, | ||
| "note": "proxy GLD unavailable or short" | ||
| } | ||
| ], | ||
| "vulnerabilities": [ | ||
| { | ||
| "factor": "equity_beta", | ||
| "exposure": 0.55, | ||
| "scenario_sensitivity": -2, | ||
| "pain": 1.1, | ||
| "note": "net long equity_beta; scenario expects equity_beta down (-2)" | ||
| }, | ||
| { | ||
| "factor": "duration", | ||
| "exposure": 0.3, | ||
| "scenario_sensitivity": -1, | ||
| "pain": 0.3, | ||
| "note": "net long duration; scenario expects duration down (-1)" | ||
| }, | ||
| { | ||
| "factor": "credit", | ||
| "exposure": 0.15, | ||
| "scenario_sensitivity": -2, | ||
| "pain": 0.3, | ||
| "note": "net long credit; scenario expects credit down (-2)" | ||
| } | ||
| ], | ||
| "inputs_digest": "10f7e77fbbec4240ac6e203bf443a6ee03fb19184dceec2950774c066796f8c6", | ||
| "would_change": [ | ||
| "reducing net equity_beta exposure toward zero removes the top vulnerability", | ||
| "a regime or market-state transition reselects the scenario stress vector" | ||
| ], | ||
| "notes": [ | ||
| "decision aid with stated scenario priors; not a covariance risk model and not a VaR claim" | ||
| ] | ||
| }, | ||
| "digest": "d277793511a0dcda199889d8beedbe849146cf0fc4cb216ce1dc77cadf20b237", | ||
| "signature": "416c38e1ed67583d2cff5b8c4020accd9d550eb49dde5956af750f4dae5b0d0ca994f564fe3db7b4a40aaab062b6236f0f8962ee5a4f4d9d00fde5400ef64506", | ||
| "verification_key": "e324c4149db27151f65df25d528d3037d3b3bc4165b0d28c0769369cba958ae3" | ||
| } |
| { | ||
| "schema": "scopeblind.gate.evidence-bundle/2", | ||
| "exported_at": "2026-06-12T11:00:00.000Z", | ||
| "gate_verification_key": "0f37d844c1934a03851a48d7927e5109be76d8cbed6eac1dc8684ed89d977790", | ||
| "manifest": { | ||
| "payload": { | ||
| "schema": "scopeblind.gate.evidence-manifest/1", | ||
| "bundle_id": "BUNDLE-SAMPLE-001", | ||
| "exported_at": "2026-06-12T11:00:00.000Z", | ||
| "completeness": { | ||
| "scope": "complete_browser_history", | ||
| "definition": "Every retained Gate history entry and nested signed record at export time.", | ||
| "history_entry_count": 1, | ||
| "history_limit": 50 | ||
| }, | ||
| "gate_verification_key": "0f37d844c1934a03851a48d7927e5109be76d8cbed6eac1dc8684ed89d977790", | ||
| "entries": [ | ||
| { | ||
| "receipt_digest": "367eac5a98bf6f3cadd18d12c9dc09c243f53bc981c6fc748e2438367cf43b5d", | ||
| "leg_digests": [ | ||
| "f5868f69977ef64189d54a174da11db41cd511a8abab380ebbc3dd08847dcc50", | ||
| "20ced3a250fe30dc7e268ade2dd770555fe03990565f769ce7e929dcc204edf9" | ||
| ], | ||
| "approval_digest": "d9325d2633fe48f43c58df1bbc876161431576f43c17db8ab5e50fdcd5a8406d", | ||
| "fill_digests": [ | ||
| "008debdc2461faaaee26cb917887e985f4596811a0e0dd16f8b9190a5610d66e", | ||
| "74b1822ee6e3c61286fb1b125f6f899becc7c6ee2d373e84cdb02d04c8c7924e" | ||
| ], | ||
| "order_state_digests": [ | ||
| "51f60ce94861f1aa6d3dfe4de7529f5e7f3eb3becdc9f480771eca9ba51a5f8e" | ||
| ] | ||
| } | ||
| ], | ||
| "record_count": 7, | ||
| "history_head_digest": "dad9529b3073f17d4ddd143d492e716ef9862a6af137f6f8660dc764be304dab" | ||
| }, | ||
| "digest": "4fe2ecfc441210b8ce2693adc40db4c26dc9d5cd66f838923542c268cf9c5324", | ||
| "signature": "59d250919aae8958c7e0f44b42d862c5a566123f5be0b9007b73f76c44bb5861d1b332a7d03a343735e59d79493c97fe1ead4106c5e15ed6197f11fba2882d01", | ||
| "verification_key": "0f37d844c1934a03851a48d7927e5109be76d8cbed6eac1dc8684ed89d977790" | ||
| }, | ||
| "entries": [ | ||
| { | ||
| "kind": "batch", | ||
| "receipt": { | ||
| "payload": { | ||
| "schema": "scopeblind.gate.batch/1", | ||
| "batch_id": "B-SAMPLE-001", | ||
| "kind": "rebalance", | ||
| "model": { | ||
| "risk": "parametric_covariance", | ||
| "correlation_matrix_digest": "6666666666666666666666666666666666666666666666666666666666666666", | ||
| "psd_repaired": false, | ||
| "min_eigenvalue_before": 0.21 | ||
| }, | ||
| "nav": 500000000, | ||
| "decision": "APPROVAL_REQUIRED", | ||
| "determining": [ | ||
| "approval-threshold" | ||
| ], | ||
| "legs": [ | ||
| { | ||
| "leg_id": "L-SAMPLE-1", | ||
| "position_id": "POS-ZN-1", | ||
| "symbol": "ZN", | ||
| "side": "buy", | ||
| "qty": 40, | ||
| "pre_position_qty": 100, | ||
| "post_position_qty": 140, | ||
| "target_pct": 2.1, | ||
| "achieved_pct": 2.05, | ||
| "residual_bp": -5 | ||
| }, | ||
| { | ||
| "leg_id": "L-SAMPLE-2", | ||
| "position_id": "POS-ES-1", | ||
| "symbol": "ES", | ||
| "side": "sell", | ||
| "qty": 25, | ||
| "pre_position_qty": 75, | ||
| "post_position_qty": 50, | ||
| "target_pct": 1.4, | ||
| "achieved_pct": 1.38, | ||
| "residual_bp": -2 | ||
| } | ||
| ], | ||
| "mandate": { | ||
| "name": "Meridian IMA (sample)", | ||
| "digest": "7777777777777777777777777777777777777777777777777777777777777777", | ||
| "parent_digest": null, | ||
| "holder": "sample-pm" | ||
| }, | ||
| "mandate_digest": "7777777777777777777777777777777777777777777777777777777777777777", | ||
| "book_digest": "8888888888888888888888888888888888888888888888888888888888888888", | ||
| "post_book_digest": "9999999999999999999999999999999999999999999999999999999999999999", | ||
| "evaluated_at": "2026-06-12T09:31:00.000Z" | ||
| }, | ||
| "digest": "367eac5a98bf6f3cadd18d12c9dc09c243f53bc981c6fc748e2438367cf43b5d", | ||
| "signature": "4f16409e9efc178775ba5ade2d2f4f1184a91983d868de34f67c885d4d1a4181dee3a45b5e6220c055a37887d703a541c6d2b86b44a34177b19171fa7036b30f", | ||
| "verification_key": "0f37d844c1934a03851a48d7927e5109be76d8cbed6eac1dc8684ed89d977790" | ||
| }, | ||
| "legs": [ | ||
| { | ||
| "payload": { | ||
| "schema": "scopeblind.gate.batch-leg/1", | ||
| "batch_id": "B-SAMPLE-001", | ||
| "batch_receipt_digest": "367eac5a98bf6f3cadd18d12c9dc09c243f53bc981c6fc748e2438367cf43b5d", | ||
| "leg_id": "L-SAMPLE-1", | ||
| "position_id": "POS-ZN-1", | ||
| "symbol": "ZN", | ||
| "side": "buy", | ||
| "qty": 40, | ||
| "pre_position_qty": 100, | ||
| "post_position_qty": 140, | ||
| "target_pct": 2.1, | ||
| "achieved_pct": 2.05, | ||
| "residual_bp": -5, | ||
| "decision": "APPROVAL_REQUIRED", | ||
| "evaluated_at": "2026-06-12T09:31:00.000Z" | ||
| }, | ||
| "digest": "f5868f69977ef64189d54a174da11db41cd511a8abab380ebbc3dd08847dcc50", | ||
| "signature": "bab776fb48a9a60764e4fc7b07f9f74f5454d3fcc78b30e52b63ba6a4edc4aee392d39683a71a85f8b5352de42cc101741bf78bcdce842ca5afbec121bfecc04", | ||
| "verification_key": "0f37d844c1934a03851a48d7927e5109be76d8cbed6eac1dc8684ed89d977790" | ||
| }, | ||
| { | ||
| "payload": { | ||
| "schema": "scopeblind.gate.batch-leg/1", | ||
| "batch_id": "B-SAMPLE-001", | ||
| "batch_receipt_digest": "367eac5a98bf6f3cadd18d12c9dc09c243f53bc981c6fc748e2438367cf43b5d", | ||
| "leg_id": "L-SAMPLE-2", | ||
| "position_id": "POS-ES-1", | ||
| "symbol": "ES", | ||
| "side": "sell", | ||
| "qty": 25, | ||
| "pre_position_qty": 75, | ||
| "post_position_qty": 50, | ||
| "target_pct": 1.4, | ||
| "achieved_pct": 1.38, | ||
| "residual_bp": -2, | ||
| "decision": "APPROVAL_REQUIRED", | ||
| "evaluated_at": "2026-06-12T09:31:00.000Z" | ||
| }, | ||
| "digest": "20ced3a250fe30dc7e268ade2dd770555fe03990565f769ce7e929dcc204edf9", | ||
| "signature": "7c253f1b165a5e193dc91a384e98fd9ab2d18c253ba329e577a61bc900d81f477f08d9a5d0019398c7c881ecac200404bfb454d4064d98c6c74c7ab551258703", | ||
| "verification_key": "0f37d844c1934a03851a48d7927e5109be76d8cbed6eac1dc8684ed89d977790" | ||
| } | ||
| ], | ||
| "approval": { | ||
| "payload": { | ||
| "schema": "scopeblind.gate.approval/1", | ||
| "evaluation_digest": "367eac5a98bf6f3cadd18d12c9dc09c243f53bc981c6fc748e2438367cf43b5d", | ||
| "scope": "basket", | ||
| "subject": "B-SAMPLE-001", | ||
| "determining": [ | ||
| "approval-threshold" | ||
| ], | ||
| "decision": "approved", | ||
| "approver": { | ||
| "name": "Sample PM phone", | ||
| "method": "device" | ||
| }, | ||
| "decided_at": "2026-06-12T09:35:00.000Z" | ||
| }, | ||
| "digest": "d9325d2633fe48f43c58df1bbc876161431576f43c17db8ab5e50fdcd5a8406d", | ||
| "signature": "54d4618f3c0c6f9481be389a0297bff33d8109cf7c7ecc1da01377b3c2377dbb1d810f65c8d9058df0590ea7a1142dbe99381765ab8b872a1ca74f9c376cb50c", | ||
| "verification_key": "7c552c8a15989d53fedc22ece565fb2bd8024a00afc15c6553998caa5386afd0" | ||
| }, | ||
| "fills": [ | ||
| { | ||
| "payload": { | ||
| "schema": "scopeblind.gate.fill/2", | ||
| "batch_id": "B-SAMPLE-001", | ||
| "batch_receipt_digest": "367eac5a98bf6f3cadd18d12c9dc09c243f53bc981c6fc748e2438367cf43b5d", | ||
| "leg_receipt_digest": "f5868f69977ef64189d54a174da11db41cd511a8abab380ebbc3dd08847dcc50", | ||
| "leg_id": "L-SAMPLE-1", | ||
| "position_id": "POS-ZN-1", | ||
| "client_order_id": "B-SAMPLE-001:L-SAMPLE-1", | ||
| "external_order_id": "EXT-001", | ||
| "execution_id": "EXEC-001", | ||
| "fill_id": "FILL-001", | ||
| "account": "Meridian Global Macro", | ||
| "venue": "Sample EMS", | ||
| "currency": "USD", | ||
| "symbol": "ZN", | ||
| "side": "buy", | ||
| "qty_ordered": 40, | ||
| "qty_filled": 40, | ||
| "remaining_qty": 0, | ||
| "status": "filled", | ||
| "remaining_authority": "none", | ||
| "filled_at": "2026-06-12T10:02:00.000Z", | ||
| "source": { | ||
| "name": "Sample custodian feed", | ||
| "independence": "independent", | ||
| "verification_key": "8969e958b59558aea3dfec2f631b1e900eb0d69fa5e368543375a3220a5850cb", | ||
| "trust": "custodian_signed" | ||
| } | ||
| }, | ||
| "digest": "008debdc2461faaaee26cb917887e985f4596811a0e0dd16f8b9190a5610d66e", | ||
| "signature": "9ebf830c4587c1daa27e8585542170ee2cfa4708467cede4778bc027cb34f16c91040f7c619d7a4685919eddcee7514589c8a0b06ccc312e6b13c7316363bd0f", | ||
| "verification_key": "8969e958b59558aea3dfec2f631b1e900eb0d69fa5e368543375a3220a5850cb" | ||
| }, | ||
| { | ||
| "payload": { | ||
| "schema": "scopeblind.gate.fill/2", | ||
| "batch_id": "B-SAMPLE-001", | ||
| "batch_receipt_digest": "367eac5a98bf6f3cadd18d12c9dc09c243f53bc981c6fc748e2438367cf43b5d", | ||
| "leg_receipt_digest": "20ced3a250fe30dc7e268ade2dd770555fe03990565f769ce7e929dcc204edf9", | ||
| "leg_id": "L-SAMPLE-2", | ||
| "position_id": "POS-ES-1", | ||
| "client_order_id": "B-SAMPLE-001:L-SAMPLE-2", | ||
| "external_order_id": "EXT-002", | ||
| "execution_id": "EXEC-002", | ||
| "fill_id": "FILL-002", | ||
| "account": "Meridian Global Macro", | ||
| "venue": "Sample EMS", | ||
| "currency": "USD", | ||
| "symbol": "ES", | ||
| "side": "sell", | ||
| "qty_ordered": 25, | ||
| "qty_filled": 10, | ||
| "remaining_qty": 15, | ||
| "status": "partial", | ||
| "remaining_authority": "none", | ||
| "filled_at": "2026-06-12T10:02:00.000Z", | ||
| "source": { | ||
| "name": "Sample custodian feed", | ||
| "independence": "independent", | ||
| "verification_key": "8969e958b59558aea3dfec2f631b1e900eb0d69fa5e368543375a3220a5850cb", | ||
| "trust": "custodian_signed" | ||
| } | ||
| }, | ||
| "digest": "74b1822ee6e3c61286fb1b125f6f899becc7c6ee2d373e84cdb02d04c8c7924e", | ||
| "signature": "9e1f1ad752e6582ec22481c9eaa3ec52da85ab1a92fb6651907d9dab9d238c904f58937c84269c0fb8e4a88fabbdb64e07c1ddb1eb7f6a62f088cee9f606800d", | ||
| "verification_key": "8969e958b59558aea3dfec2f631b1e900eb0d69fa5e368543375a3220a5850cb" | ||
| } | ||
| ], | ||
| "order_states": [ | ||
| { | ||
| "payload": { | ||
| "schema": "scopeblind.gate.order-state/1", | ||
| "batch_id": "B-SAMPLE-001", | ||
| "batch_receipt_digest": "367eac5a98bf6f3cadd18d12c9dc09c243f53bc981c6fc748e2438367cf43b5d", | ||
| "leg_receipt_digest": "20ced3a250fe30dc7e268ade2dd770555fe03990565f769ce7e929dcc204edf9", | ||
| "fill_receipt_digest": "74b1822ee6e3c61286fb1b125f6f899becc7c6ee2d373e84cdb02d04c8c7924e", | ||
| "leg_id": "L-SAMPLE-2", | ||
| "client_order_id": "B-SAMPLE-001:L-SAMPLE-2", | ||
| "state": "partially_filled_remainder_held", | ||
| "remaining_qty": 15, | ||
| "authorized_remaining_qty": 0, | ||
| "requires_new_decision": true, | ||
| "recorded_at": "2026-06-12T10:02:00.000Z" | ||
| }, | ||
| "digest": "51f60ce94861f1aa6d3dfe4de7529f5e7f3eb3becdc9f480771eca9ba51a5f8e", | ||
| "signature": "7d3cb0548d2aba37af5a63531c9c176dd144df783cd29bbcdf8b7cb1ec55e7580c6c9262d167bfeffc36ac5bd37e0c157302d7c5c18cdb9910fbbdf7f419d60a", | ||
| "verification_key": "0f37d844c1934a03851a48d7927e5109be76d8cbed6eac1dc8684ed89d977790" | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| } |
| { | ||
| "payload": { | ||
| "schema": "scopeblind.gate.decision/2", | ||
| "proposal_id": "P-SAMPLE-001", | ||
| "proposal": { | ||
| "symbol": "ZN", | ||
| "side": "buy", | ||
| "qty": 40 | ||
| }, | ||
| "decision": "ALLOW", | ||
| "determining": [], | ||
| "checks": [ | ||
| { | ||
| "rule": "gross-exposure-cap", | ||
| "status": "pass" | ||
| }, | ||
| { | ||
| "rule": "restricted-list", | ||
| "status": "pass" | ||
| } | ||
| ], | ||
| "mandate": { | ||
| "name": "Meridian IMA (sample)", | ||
| "digest": "7777777777777777777777777777777777777777777777777777777777777777", | ||
| "parent_digest": null, | ||
| "holder": "sample-pm" | ||
| }, | ||
| "mandate_digest": "7777777777777777777777777777777777777777777777777777777777777777", | ||
| "book_digest": "8888888888888888888888888888888888888888888888888888888888888888", | ||
| "nav": 500000000, | ||
| "evaluated_at": "2026-06-12T09:30:00.000Z" | ||
| }, | ||
| "digest": "a60f082df4a13e4c5e1aaf8ed6e2a1e323d47e81ed2395644531176281285dfa", | ||
| "signature": "74f3ed4710b440ab63e465c8ae86e4fcd453f5fb9a2128acfe65ad2ebfbe9bd3d0afefe7567e6cf050c317d1e6e48e8c8be848f1998a7b91a625e1f6a94a5d05", | ||
| "verification_key": "0f37d844c1934a03851a48d7927e5109be76d8cbed6eac1dc8684ed89d977790" | ||
| } |
| /** ScopeBlind Gate receipt and evidence-bundle verifier. */ | ||
| import { ed25519 } from '@noble/curves/ed25519'; | ||
| import { sha256 } from '@noble/hashes/sha256'; | ||
| import { utf8ToBytes } from '@noble/hashes/utils'; | ||
| import { sortKeysDeep } from '../util/canonical.js'; | ||
| import { hexToBytes, bytesToHex } from '../util/hex.js'; | ||
| import { isMacroSchema, macroSchemaErrors, macroSummary, MACRO_PROVES, MACRO_LIMITATIONS } from './macro-snapshot.js'; | ||
| export const GATE_BUNDLE_SCHEMA = 'scopeblind.gate.evidence-bundle/2'; | ||
| export const GATE_SCHEMAS = { | ||
| 'scopeblind.gate.decision/2': 'single order decision', | ||
| 'scopeblind.gate.batch/1': 'batch decision', | ||
| 'scopeblind.gate.batch-leg/1': 'batch leg', | ||
| 'scopeblind.gate.approval/1': 'PM approval', | ||
| 'scopeblind.gate.fill/1': 'legacy fill', | ||
| 'scopeblind.gate.fill/2': 'execution fill', | ||
| 'scopeblind.gate.order-state/1': 'held remainder authority state', | ||
| 'scopeblind.gate.evidence-manifest/1': 'signed bundle manifest', | ||
| 'scopeblind.gate.reconciliation/1': 'custodian-statement reconciliation', | ||
| 'scopeblind.gate.stress/1': 'regime stress-test result', | ||
| 'scopeblind.mandate.delegation/1': 'issuer-signed mandate delegation', | ||
| }; | ||
| const HEX_64 = /^[0-9a-f]{64}$/; | ||
| const DECISIONS = new Set(['ALLOW', 'APPROVAL_REQUIRED', 'DENY', 'REVIEW']); | ||
| const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v); | ||
| const isString = (v) => typeof v === 'string' && v.length > 0; | ||
| const isNumber = (v) => typeof v === 'number' && Number.isFinite(v); | ||
| const same = (a, b) => canonicalGateJSON(a) === canonicalGateJSON(b); | ||
| export function canonicalGateJSON(payload) { | ||
| return JSON.stringify(sortKeysDeep(payload)); | ||
| } | ||
| export const GATE_PROVES = [ | ||
| 'Authenticity: each record was signed by the Ed25519 key attributed to its role.', | ||
| 'Integrity: neither payload nor digest has been modified since signing.', | ||
| 'Schema validity: recognized records satisfy their required semantic contract.', | ||
| ]; | ||
| export const GATE_BUNDLE_PROVES = GATE_PROVES.concat([ | ||
| 'Exact chain consistency: child content matches the signed parent, not only its digest.', | ||
| 'Manifest completeness: the signed manifest exactly enumerates every exported record.', | ||
| ]); | ||
| export const GATE_LIMITATIONS = [ | ||
| 'Correctness of the underlying risk inputs or external market data unless separately attested.', | ||
| 'Independent execution corroboration when a fill source explicitly identifies itself as a same-browser demo key.', | ||
| 'Global history completeness beyond the manifest scope; the exporter signs what it claims was retained at export time.', | ||
| ]; | ||
| function requireFields(payload, fields, errors) { | ||
| for (const field of fields) if (payload[field] === undefined || payload[field] === null || payload[field] === '') errors.push(`missing ${field}`); | ||
| } | ||
| function schemaErrors(payload) { | ||
| const errors = []; | ||
| const schema = payload.schema; | ||
| if (!Object.hasOwn(GATE_SCHEMAS, schema)) return errors; | ||
| if (schema === 'scopeblind.gate.decision/2') { | ||
| requireFields(payload, ['proposal_id', 'proposal', 'decision', 'mandate_digest', 'book_digest', 'nav', 'evaluated_at'], errors); | ||
| if (!DECISIONS.has(payload.decision)) errors.push('invalid decision'); | ||
| if (!isObject(payload.proposal) || !isString(payload.proposal.symbol) || !['buy', 'sell'].includes(payload.proposal.side) || !(payload.proposal.qty > 0)) errors.push('invalid proposal'); | ||
| } else if (schema === 'scopeblind.gate.batch/1') { | ||
| requireFields(payload, ['batch_id', 'decision', 'legs', 'mandate_digest', 'book_digest', 'post_book_digest', 'nav', 'evaluated_at'], errors); | ||
| if (!DECISIONS.has(payload.decision)) errors.push('invalid decision'); | ||
| if (!Array.isArray(payload.legs) || payload.legs.length === 0) errors.push('legs must be non-empty'); | ||
| else { | ||
| const ids = new Set(); | ||
| for (const leg of payload.legs) { | ||
| if (!isObject(leg) || !isString(leg.leg_id) || !isString(leg.symbol) || !['buy', 'sell'].includes(leg.side) || !(leg.qty > 0)) errors.push('invalid parent leg summary'); | ||
| if (ids.has(leg?.leg_id)) errors.push(`duplicate parent leg_id ${leg?.leg_id}`); | ||
| ids.add(leg?.leg_id); | ||
| } | ||
| } | ||
| } else if (schema === 'scopeblind.gate.batch-leg/1') { | ||
| requireFields(payload, ['batch_id', 'batch_receipt_digest', 'leg_id', 'symbol', 'side', 'qty', 'decision', 'evaluated_at'], errors); | ||
| if (!HEX_64.test(payload.batch_receipt_digest || '')) errors.push('invalid batch_receipt_digest'); | ||
| if (!['buy', 'sell'].includes(payload.side) || !(payload.qty > 0) || !DECISIONS.has(payload.decision)) errors.push('invalid leg semantics'); | ||
| } else if (schema === 'scopeblind.gate.approval/1') { | ||
| requireFields(payload, ['evaluation_digest', 'scope', 'subject', 'decision', 'approver', 'decided_at'], errors); | ||
| if (!HEX_64.test(payload.evaluation_digest || '')) errors.push('invalid evaluation_digest'); | ||
| if (!['approved', 'declined'].includes(payload.decision)) errors.push('invalid approval decision'); | ||
| if (!['basket', 'order'].includes(payload.scope)) errors.push('invalid approval scope'); | ||
| } else if (schema === 'scopeblind.gate.fill/1' || schema === 'scopeblind.gate.fill/2') { | ||
| requireFields(payload, ['batch_receipt_digest', 'leg_id', 'symbol', 'side', 'qty_ordered', 'qty_filled', 'status', 'filled_at', 'source'], errors); | ||
| if (!['filled', 'partial'].includes(payload.status) || !['buy', 'sell'].includes(payload.side)) errors.push('invalid fill semantics'); | ||
| if (!(payload.qty_ordered > 0) || !(payload.qty_filled >= 0) || payload.qty_filled > payload.qty_ordered) errors.push('invalid fill quantities'); | ||
| if (payload.status === 'filled' && payload.qty_filled !== payload.qty_ordered) errors.push('filled status requires full quantity'); | ||
| if (payload.status === 'partial' && !(payload.qty_filled < payload.qty_ordered)) errors.push('partial status requires a remainder'); | ||
| if (schema.endsWith('/2')) { | ||
| requireFields(payload, ['batch_id', 'leg_receipt_digest', 'client_order_id', 'external_order_id', 'execution_id', 'fill_id', 'account', 'venue', 'currency', 'remaining_qty', 'remaining_authority'], errors); | ||
| if (payload.remaining_qty !== payload.qty_ordered - payload.qty_filled) errors.push('remaining_qty arithmetic mismatch'); | ||
| if (payload.remaining_authority !== 'none') errors.push('remaining_authority must be none'); | ||
| if (!isObject(payload.source)) errors.push('invalid fill source'); | ||
| else { | ||
| requireFields(payload.source, ['name', 'independence', 'verification_key', 'trust'], errors); | ||
| if (!HEX_64.test(payload.source.verification_key || '')) errors.push('invalid source key'); | ||
| } | ||
| } | ||
| } else if (schema === 'scopeblind.gate.order-state/1') { | ||
| requireFields(payload, ['batch_id', 'batch_receipt_digest', 'leg_receipt_digest', 'fill_receipt_digest', 'leg_id', 'client_order_id', 'state', 'remaining_qty', 'authorized_remaining_qty', 'requires_new_decision', 'recorded_at'], errors); | ||
| if (payload.state !== 'partially_filled_remainder_held' || !(payload.remaining_qty > 0) || payload.authorized_remaining_qty !== 0 || payload.requires_new_decision !== true) errors.push('order state does not fail closed'); | ||
| } else if (schema === 'scopeblind.gate.evidence-manifest/1') { | ||
| requireFields(payload, ['bundle_id', 'exported_at', 'completeness', 'gate_verification_key', 'entries', 'record_count', 'history_head_digest'], errors); | ||
| if (!Array.isArray(payload.entries)) errors.push('manifest entries must be an array'); | ||
| if (!isObject(payload.completeness) || !isString(payload.completeness.scope) || !isString(payload.completeness.definition)) errors.push('invalid completeness definition'); | ||
| } else if (schema === 'scopeblind.mandate.delegation/1') { | ||
| requireFields(payload, ['delegation_id', 'parent_mandate_digest', 'child_mandate_digest', 'holder_verification_key', 'issuer_verification_key', 'scope', 'issued_at', 'expires_at', 'nonce', 'revocation'], errors); | ||
| if (!HEX_64.test(payload.parent_mandate_digest || '') || !HEX_64.test(payload.child_mandate_digest || '') || !HEX_64.test(payload.holder_verification_key || '') || !HEX_64.test(payload.issuer_verification_key || '')) errors.push('invalid delegation digest or key'); | ||
| if (payload.scope?.principal !== 'Agent::pm' || payload.scope?.action !== 'Action::trade' || payload.scope?.resource !== 'Portfolio::main') errors.push('invalid delegation scope'); | ||
| if (Date.parse(payload.expires_at) <= Date.parse(payload.issued_at)) errors.push('invalid delegation expiry'); | ||
| if (payload.revocation?.status !== 'not_revoked' || !isString(payload.revocation?.reference)) errors.push('invalid revocation reference'); | ||
| } | ||
| return errors; | ||
| } | ||
| function collectGatePayloadFields(payload) { | ||
| const fields = {}; | ||
| for (const k of ['schema', 'decision', 'proposal_id', 'batch_id', 'kind', 'leg_id', 'client_order_id', 'external_order_id', 'execution_id', 'fill_id', 'symbol', 'side', 'qty', 'qty_ordered', 'qty_filled', 'remaining_qty', 'status', 'scope', 'subject', 'nav', 'mandate_digest', 'book_digest', 'post_book_digest', 'evaluated_at', 'decided_at', 'filled_at']) { | ||
| if (payload[k] !== undefined && payload[k] !== null) fields[k] = payload[k]; | ||
| } | ||
| if (Array.isArray(payload.determining)) fields.determining = payload.determining; | ||
| if (Array.isArray(payload.legs)) fields.leg_count = payload.legs.length; | ||
| return fields; | ||
| } | ||
| function collectGateChainFields(payload) { | ||
| const links = {}; | ||
| for (const k of ['batch_receipt_digest', 'leg_receipt_digest', 'fill_receipt_digest', 'evaluation_digest']) if (isString(payload[k])) links[k] = payload[k]; | ||
| if (isString(payload.leg_id)) links.leg_id = payload.leg_id; | ||
| return Object.keys(links).length ? links : null; | ||
| } | ||
| export function verifyGateTuple(tuple, opts = {}) { | ||
| const base = { format: 'gate-tuple', schema: null, schemaRecognized: false }; | ||
| if (!isObject(tuple)) return { valid: false, error: 'unknown_format', ...base, detail: 'tuple is not an object' }; | ||
| const payload = tuple.payload; | ||
| if (!isObject(payload)) return { valid: false, error: 'missing_payload', ...base }; | ||
| const schema = isString(payload.schema) ? payload.schema : null; | ||
| const macro = schema !== null && isMacroSchema(schema); | ||
| const schemaRecognized = schema !== null && (Object.hasOwn(GATE_SCHEMAS, schema) || macro); | ||
| Object.assign(base, { schema, schemaRecognized, macroSchema: macro, type: schema || undefined, payloadFields: collectGatePayloadFields(payload), chainFields: macro ? null : collectGateChainFields(payload) }); | ||
| if (macro) base.macroSummary = macroSummary(payload); | ||
| if (!isString(tuple.signature)) return { valid: false, error: 'missing_signature', ...base }; | ||
| if (!isString(tuple.digest) || !HEX_64.test(tuple.digest)) return { valid: false, error: 'malformed_hex', ...base, detail: 'tuple digest must be 64 lowercase hex characters' }; | ||
| if (!isString(tuple.verification_key)) return { valid: false, error: 'no_public_key', ...base }; | ||
| const pinned = isString(opts.publicKey); | ||
| if (pinned && opts.publicKey.toLowerCase() !== tuple.verification_key.toLowerCase()) return { valid: false, error: 'key_mismatch', ...base, publicKey: tuple.verification_key, expectedKey: opts.publicKey }; | ||
| const recomputed = bytesToHex(sha256(utf8ToBytes(canonicalGateJSON(payload)))); | ||
| if (recomputed !== tuple.digest) return { valid: false, error: 'digest_mismatch', ...base, digest: tuple.digest, recomputedDigest: recomputed, publicKey: tuple.verification_key }; | ||
| try { | ||
| if (!ed25519.verify(hexToBytes(tuple.signature), hexToBytes(tuple.digest), hexToBytes(tuple.verification_key))) return { valid: false, error: 'invalid_signature', ...base, digest: tuple.digest, publicKey: tuple.verification_key }; | ||
| } catch (e) { | ||
| return { valid: false, error: 'malformed_hex', ...base, digest: tuple.digest, detail: e.message }; | ||
| } | ||
| const semanticErrors = macro ? macroSchemaErrors(payload) : schemaErrors(payload); | ||
| if (semanticErrors.length) return { valid: false, error: 'schema_invalid', ...base, digest: tuple.digest, publicKey: tuple.verification_key, detail: semanticErrors.join('; '), semanticErrors }; | ||
| return { | ||
| valid: true, | ||
| ...base, | ||
| digest: tuple.digest, | ||
| publicKey: tuple.verification_key, | ||
| keySource: pinned ? 'embedded-tuple (pinned via --key)' : 'embedded-tuple', | ||
| signerPinned: pinned, | ||
| identityStatus: pinned ? 'pinned_expected_key' : 'embedded_key_only', | ||
| algorithm: 'ed25519', | ||
| proves: macro ? MACRO_PROVES : GATE_PROVES, | ||
| limitations: macro ? MACRO_LIMITATIONS : GATE_LIMITATIONS, | ||
| }; | ||
| } | ||
| function exactLegCheck(leg, parent, parentDigest) { | ||
| const p = leg?.payload; const pp = parent?.payload; | ||
| if (!isObject(p) || !isObject(pp) || pp.schema !== 'scopeblind.gate.batch/1') return 'batch leg requires a batch parent'; | ||
| if (p.batch_receipt_digest !== parentDigest) return 'batch_receipt_digest does not match the entry receipt digest'; | ||
| const matches = pp.legs.filter((x) => x?.leg_id === p.leg_id); | ||
| if (matches.length !== 1) return `leg_id ${JSON.stringify(p.leg_id)} must appear exactly once in parent legs`; | ||
| const expected = { ...matches[0], batch_id: pp.batch_id, decision: pp.decision, evaluated_at: pp.evaluated_at }; | ||
| for (const [k, v] of Object.entries(expected)) if (!same(p[k], v)) return `leg field ${k} does not match signed parent`; | ||
| return null; | ||
| } | ||
| function approvalCheck(approval, parent, parentDigest) { | ||
| const p = approval?.payload; const pp = parent?.payload; | ||
| if (p?.evaluation_digest !== parentDigest) return 'evaluation_digest does not match the entry receipt digest'; | ||
| const expectedScope = pp?.schema === 'scopeblind.gate.batch/1' ? 'basket' : 'order'; | ||
| if (p?.scope !== expectedScope) return `approval scope must be ${expectedScope}`; | ||
| if (pp?.decision !== 'APPROVAL_REQUIRED') return 'approval is only valid for an APPROVAL_REQUIRED parent'; | ||
| if (!same(p?.determining ?? [], pp?.determining ?? [])) return 'approval determining rules do not match parent'; | ||
| return null; | ||
| } | ||
| function fillCheck(fill, parent, legsById, legTuples) { | ||
| const p = fill?.payload; const pp = parent?.payload; | ||
| if (p?.batch_receipt_digest !== parent?.digest) return 'batch_receipt_digest does not match the entry receipt digest'; | ||
| const summary = legsById.get(p?.leg_id); | ||
| const leg = legTuples.get(p?.leg_id); | ||
| if (!summary || !leg) return `leg_id ${JSON.stringify(p?.leg_id)} is not represented by exactly one signed leg`; | ||
| for (const k of ['symbol', 'side']) if (p[k] !== summary[k]) return `fill field ${k} does not match signed leg`; | ||
| if (p.qty_ordered !== summary.qty) return 'fill qty_ordered does not match signed leg qty'; | ||
| if (p.schema === 'scopeblind.gate.fill/2') { | ||
| if (p.batch_id !== pp.batch_id) return 'fill batch_id does not match parent'; | ||
| if (p.leg_receipt_digest !== leg.digest) return 'leg_receipt_digest does not match signed leg'; | ||
| if (p.source?.verification_key?.toLowerCase() !== fill.verification_key?.toLowerCase()) return 'fill source key does not match fill signer'; | ||
| } | ||
| return null; | ||
| } | ||
| function orderStateCheck(state, parent, legsById, legTuples, fillsByDigest) { | ||
| const p = state?.payload; const summary = legsById.get(p?.leg_id); const leg = legTuples.get(p?.leg_id); const fill = fillsByDigest.get(p?.fill_receipt_digest); | ||
| if (!summary || !leg || !fill) return 'order state cannot resolve its leg and fill'; | ||
| if (p.batch_receipt_digest !== parent.digest || p.leg_receipt_digest !== leg.digest) return 'order state parent digest mismatch'; | ||
| if (fill.payload.status !== 'partial' || p.remaining_qty !== fill.payload.remaining_qty || p.client_order_id !== fill.payload.client_order_id) return 'order state does not exactly match the partial fill'; | ||
| return null; | ||
| } | ||
| function manifestShape(entries) { | ||
| return entries.map((e) => ({ | ||
| receipt_digest: e.receipt?.digest, | ||
| leg_digests: (e.legs || []).map((x) => x?.digest), | ||
| approval_digest: e.approval?.digest ?? null, | ||
| fill_digests: (e.fills || []).map((x) => x?.digest), | ||
| order_state_digests: (e.order_states || []).map((x) => x?.digest), | ||
| })); | ||
| } | ||
| export function verifyGateBundle(bundle, opts = {}) { | ||
| const results = { valid: true, format: 'gate-evidence-bundle', schema: bundle?.schema, exportedAt: bundle?.exported_at, gateVerificationKey: isString(bundle?.gate_verification_key) ? bundle.gate_verification_key : null, entryCount: 0, total: 0, passed: 0, failed: 0, cryptoFailed: 0, chainChecks: 0, chainFailed: 0, errors: [], records: [], signers: [], entriesUseGateKey: true, manifestValid: null, proves: GATE_BUNDLE_PROVES, limitations: GATE_LIMITATIONS }; | ||
| if (!isObject(bundle) || !Array.isArray(bundle.entries) || !['scopeblind.gate.evidence-bundle/1', GATE_BUNDLE_SCHEMA].includes(bundle.schema)) return { ...results, valid: false, error: 'unknown_format', detail: 'unsupported bundle schema or missing entries array' }; | ||
| results.entryCount = bundle.entries.length; | ||
| if (!HEX_64.test(results.gateVerificationKey || '')) return { ...results, valid: false, error: 'no_public_key', detail: 'bundle requires gate_verification_key' }; | ||
| const gateKey = results.gateVerificationKey.toLowerCase(); | ||
| if (isString(opts.publicKey) && opts.publicKey.toLowerCase() !== gateKey) return { ...results, valid: false, error: 'key_mismatch', detail: 'bundle gate_verification_key does not match --key' }; | ||
| const signerRoles = new Map(); let firstError = null; | ||
| const add = (tuple, role, entry, chainError = null, pinGate = false) => { | ||
| results.total++; | ||
| const r = verifyGateTuple(tuple, pinGate ? { publicKey: gateKey } : {}); | ||
| const key = isString(tuple?.verification_key) ? tuple.verification_key.toLowerCase() : null; | ||
| if (key) { if (!signerRoles.has(key)) signerRoles.set(key, new Set()); signerRoles.get(key).add(role); } | ||
| const record = { entry: entry + 1, role, id: tuple?.payload?.leg_id ?? tuple?.payload?.subject ?? tuple?.payload?.proposal_id ?? tuple?.payload?.batch_id, schema: r.schema, schemaRecognized: r.schemaRecognized, cryptoValid: r.valid, cryptoError: r.valid ? undefined : r.error, chainValid: chainError === null ? null : false, chainDetail: chainError || undefined, signer: tuple?.verification_key, digest: tuple?.digest }; | ||
| results.records.push(record); | ||
| if (!r.valid) { results.cryptoFailed++; results.valid = false; firstError ||= r.error; results.errors.push(`[crypto] Entry ${entry + 1} ${role}: ${r.error}${r.detail ? ` (${r.detail})` : ''}`); } | ||
| if (chainError !== null) { results.chainChecks++; results.chainFailed++; results.valid = false; results.errors.push(`[chain] Entry ${entry + 1} ${role}: ${chainError}`); } | ||
| else if (role !== 'receipt' && role !== 'manifest' && role !== 'delegation') results.chainChecks++; | ||
| if (r.valid && chainError === null) results.passed++; else results.failed++; | ||
| return r; | ||
| }; | ||
| if (bundle.schema === GATE_BUNDLE_SCHEMA) { | ||
| const mr = add(bundle.manifest, 'manifest', -1, null, true); | ||
| results.manifestValid = mr.valid; | ||
| if (mr.valid) { | ||
| const p = bundle.manifest.payload; | ||
| const expected = manifestShape(bundle.entries); | ||
| const recordCount = expected.reduce((n, e) => n + 1 + e.leg_digests.length + (e.approval_digest ? 1 : 0) + e.fill_digests.length + e.order_state_digests.length, 0); | ||
| const historyHead = bytesToHex(sha256(utf8ToBytes(canonicalGateJSON(bundle.entries.map((e) => e?.receipt?.digest))))); | ||
| const manifestError = p.gate_verification_key !== gateKey ? 'manifest gate key mismatch' | ||
| : p.exported_at !== bundle.exported_at ? 'manifest export time mismatch' | ||
| : !same(p.entries, expected) ? 'manifest digest inventory does not exactly match bundle entries' | ||
| : p.record_count !== recordCount ? 'manifest record_count mismatch' | ||
| : p.completeness?.history_entry_count !== bundle.entries.length ? 'manifest completeness count mismatch' | ||
| : p.history_head_digest !== historyHead ? 'manifest history_head_digest mismatch' | ||
| : null; | ||
| if (manifestError) { results.valid = false; results.manifestValid = false; results.chainChecks++; results.chainFailed++; results.errors.push(`[chain] Manifest: ${manifestError}`); } | ||
| else results.chainChecks++; | ||
| } | ||
| } else { | ||
| results.manifestValid = false; | ||
| results.valid = false; | ||
| results.chainChecks++; results.chainFailed++; | ||
| results.errors.push('[chain] Legacy bundle has no signed completeness manifest; export evidence-bundle/2'); | ||
| } | ||
| for (let i = 0; i < bundle.entries.length; i++) { | ||
| const e = bundle.entries[i] || {}; const parent = e.receipt; | ||
| add(parent, 'receipt', i, null, true); | ||
| if (parent?.verification_key?.toLowerCase() !== gateKey) results.entriesUseGateKey = false; | ||
| const summaries = Array.isArray(parent?.payload?.legs) ? parent.payload.legs : []; | ||
| const legsById = new Map(summaries.map((x) => [x.leg_id, x])); | ||
| const legTuples = new Map(); | ||
| for (const leg of Array.isArray(e.legs) ? e.legs : []) { | ||
| const err = exactLegCheck(leg, parent, parent?.digest); | ||
| add(leg, 'leg', i, err, true); | ||
| if (!legTuples.has(leg?.payload?.leg_id)) legTuples.set(leg?.payload?.leg_id, leg); | ||
| else { results.valid = false; results.chainFailed++; results.errors.push(`[chain] Entry ${i + 1} duplicate signed leg ${leg?.payload?.leg_id}`); } | ||
| } | ||
| if (parent?.payload?.schema === 'scopeblind.gate.batch/1' && (e.legs || []).length !== summaries.length) { results.valid = false; results.chainChecks++; results.chainFailed++; results.errors.push(`[chain] Entry ${i + 1} signed leg count does not match parent`); } | ||
| if (e.approval) add(e.approval, 'approval', i, approvalCheck(e.approval, parent, parent?.digest), false); | ||
| // Execution evidence requires a RELEASED decision. An ALLOW parent is | ||
| // released by definition; an APPROVAL_REQUIRED parent is released only by | ||
| // a present approval whose decision is "approved" (its own crypto and | ||
| // chain validity are checked above). Fills under a DENY, REVIEW, held, or | ||
| // declined parent are evidence of an unauthorized execution and fail the | ||
| // bundle even when every signature is individually valid. | ||
| const fillsPresent = Array.isArray(e.fills) && e.fills.length > 0; | ||
| if (fillsPresent) { | ||
| const decision = parent?.payload?.decision; | ||
| const released = decision === 'ALLOW' | ||
| || (decision === 'APPROVAL_REQUIRED' && e.approval?.payload?.decision === 'approved'); | ||
| results.chainChecks++; | ||
| if (!released) { | ||
| results.valid = false; | ||
| results.chainFailed++; | ||
| const why = decision === 'APPROVAL_REQUIRED' | ||
| ? (e.approval ? `approval decision is ${JSON.stringify(e.approval?.payload?.decision)}` : 'no approval is present') | ||
| : `parent decision is ${JSON.stringify(decision)}`; | ||
| results.errors.push(`[chain] Entry ${i + 1}: fills present but the decision was never released (${why})`); | ||
| } | ||
| } | ||
| const fillsByDigest = new Map(); | ||
| for (const fill of Array.isArray(e.fills) ? e.fills : []) { const err = fillCheck(fill, parent, legsById, legTuples); add(fill, 'fill', i, err, false); fillsByDigest.set(fill?.digest, fill); } | ||
| const statesByFill = new Map(); | ||
| for (const state of Array.isArray(e.order_states) ? e.order_states : []) { | ||
| add(state, 'order_state', i, orderStateCheck(state, parent, legsById, legTuples, fillsByDigest), true); | ||
| statesByFill.set(state?.payload?.fill_receipt_digest, state); | ||
| } | ||
| for (const fill of Array.isArray(e.fills) ? e.fills : []) { | ||
| if (fill?.payload?.status === 'partial' && !statesByFill.has(fill.digest)) { | ||
| results.valid = false; | ||
| results.chainChecks++; | ||
| results.chainFailed++; | ||
| results.errors.push(`[chain] Entry ${i + 1} partial fill ${fill?.payload?.fill_id ?? fill?.digest} has no gate-signed held-remainder state`); | ||
| } | ||
| } | ||
| const delegation = parent?.payload?.mandate?.delegation; | ||
| if (delegation) { | ||
| const p = delegation.payload; | ||
| const err = p?.child_mandate_digest !== parent.payload.mandate_digest ? 'delegation child digest does not match decision mandate' | ||
| : p?.parent_mandate_digest !== parent.payload.mandate?.parent_digest ? 'delegation parent digest does not match decision lineage' | ||
| : p?.holder_verification_key?.toLowerCase() !== parent.verification_key?.toLowerCase() ? 'delegation holder key does not match decision signer' | ||
| : p?.issuer_verification_key?.toLowerCase() !== delegation.verification_key?.toLowerCase() ? 'delegation issuer key mismatch' | ||
| : Date.parse(p?.expires_at) <= Date.parse(parent.payload.evaluated_at) ? 'delegation expired before decision' | ||
| : null; | ||
| add(delegation, 'delegation', i, err, false); | ||
| } | ||
| } | ||
| results.signers = [...signerRoles.entries()].map(([key, roles]) => ({ key, roles: [...roles].sort(), isGateKey: key === gateKey })); | ||
| if (!results.valid) results.error = firstError || 'chain_link_mismatch'; | ||
| return results; | ||
| } |
| /** | ||
| * Legate governed-receipt verifier. | ||
| * | ||
| * The Legate runtime (desktop daemon) and the iPhone co-sign sign a FLAT, | ||
| * pipe-delimited canonical payload — NOT a JSON tuple with a digest field — | ||
| * so the gate-receipt engine does not recognize it. This engine closes that | ||
| * gap so a third party (an allocator, an LP, a counterparty) can re-verify the | ||
| * exact bytes the daemon and phone co-signed with the OPEN tool, holding no | ||
| * ScopeBlind code and trusting no fund-attested wrapper. | ||
| * | ||
| * Canonical signed payload (must stay byte-identical to | ||
| * scopeblind-pm/src/governed-actions.ts:legateReceiptPayload and the desktop | ||
| * daemon + phone): | ||
| * | ||
| * scopeblind.receipt.v1|<id>|<tool>|<decision>|<input_sha256>|<result_sha256>|<at> | ||
| * | ||
| * The signature is Ed25519 over the UTF-8 bytes of that string directly (it is | ||
| * NOT hashed first), which is what distinguishes this from the Gate receipt | ||
| * tuple (Ed25519 over a SHA-256 payload digest). | ||
| * | ||
| * @module verify-cli/src/engines/legate-governed-receipt | ||
| * @license Apache-2.0 | ||
| */ | ||
| import { ed25519 } from '@noble/curves/ed25519'; | ||
| import { utf8ToBytes } from '@noble/hashes/utils'; | ||
| import { hexToBytes } from '../util/hex.js'; | ||
| /** The canonical payload prefix this engine recognizes (v1). */ | ||
| export const LEGATE_RECEIPT_TAG = 'scopeblind.receipt.v1'; | ||
| /** | ||
| * Governed action kinds, keyed by the receipt's `tool`. Kept in sync with | ||
| * scopeblind-pm/src/governed-actions.ts:GOVERNED_RECEIPT_KINDS. `tool` | ||
| * distinguishes the step; every kind is the same Ed25519-over-canonical-payload | ||
| * envelope. | ||
| */ | ||
| export const GOVERNED_RECEIPT_KINDS = { | ||
| 'gate.approve': 'approval', | ||
| 'gate.deny': 'denial', | ||
| instruction: 'execution-instruction', | ||
| execution_claim: 'execution-claim', | ||
| 'submandate.issue': 'delegation', | ||
| 'submandate.revoke': 'revocation', | ||
| 'policy.commit': 'mandate-commit', | ||
| 'recipe.mint': 'recipe-mint', | ||
| 'recipe.run': 'recipe-run', | ||
| 'recipe.revoke': 'recipe-revocation', | ||
| 'recipe.reconcile': 'recipe-corroboration', | ||
| // Restraint: a gate-signed proof that an out-of-mandate action was BLOCKED | ||
| // before execution (the negative-space artifact). See engines/restraint.js. | ||
| 'gate.restrain': 'restraint', | ||
| }; | ||
| const HEX = (s) => typeof s === 'string' && /^[0-9a-f]+$/i.test(s) && s.length % 2 === 0; | ||
| const isString = (v) => typeof v === 'string' && v.length > 0; | ||
| /** Reconstruct the exact bytes the daemon + phone signed. */ | ||
| export function legateReceiptPayload(r) { | ||
| return [LEGATE_RECEIPT_TAG, r.id, r.tool, r.decision, r.input_sha256, r.result_sha256, r.at].join('|'); | ||
| } | ||
| export const GOVERNED_PROVES = [ | ||
| 'Authenticity: the receipt was signed by the Ed25519 key it carries (the Legate runtime or phone co-sign key).', | ||
| 'Integrity: the action identity — tool, decision, the input and result SHA-256 hashes, and the timestamp — is bound by the signature and unmodified.', | ||
| 'Recognition: the receipt declares a known governed-action kind.', | ||
| ]; | ||
| export const GOVERNED_LIMITATIONS = [ | ||
| 'Whether the embedded verification_key is the key your counterparty expects — pin it with --key to bind trust to a known signer.', | ||
| 'Correctness of the inputs and results behind input_sha256 / result_sha256 unless those preimages are separately disclosed and re-hashed.', | ||
| 'Independent execution corroboration — only a recipe.reconcile receipt carries the custodian-corroborated grade.', | ||
| ]; | ||
| /** | ||
| * Verify a single Legate governed receipt. | ||
| * | ||
| * @param {object} receipt flat envelope { id, at, tool, decision, input_sha256, result_sha256, signature, verification_key, type? } | ||
| * @param {{ publicKey?: string }} [opts] | ||
| */ | ||
| export function verifyLegateGovernedReceipt(receipt, opts = {}) { | ||
| const base = { | ||
| format: 'legate-governed-receipt', | ||
| schema: isString(receipt?.type) ? receipt.type : 'scopeblind.agent_vault.receipt.v1', | ||
| tool: isString(receipt?.tool) ? receipt.tool : undefined, | ||
| kind: undefined, | ||
| kindRecognized: false, | ||
| algorithm: 'ed25519', | ||
| }; | ||
| if (receipt === null || typeof receipt !== 'object' || Array.isArray(receipt)) { | ||
| return { valid: false, error: 'unknown_format', ...base, detail: 'receipt is not an object' }; | ||
| } | ||
| const kind = GOVERNED_RECEIPT_KINDS[receipt.tool]; | ||
| base.kind = kind || (isString(receipt.decision) ? `decision:${receipt.decision}` : 'receipt'); | ||
| base.kindRecognized = Boolean(kind); | ||
| base.payloadFields = { | ||
| id: receipt.id, | ||
| tool: receipt.tool, | ||
| decision: receipt.decision, | ||
| input_sha256: receipt.input_sha256, | ||
| result_sha256: receipt.result_sha256, | ||
| at: receipt.at, | ||
| }; | ||
| for (const field of ['id', 'tool', 'decision', 'input_sha256', 'result_sha256', 'at']) { | ||
| if (!isString(receipt[field])) return { valid: false, error: 'malformed_payload', ...base, detail: `missing or empty ${field}` }; | ||
| } | ||
| if (!isString(receipt.signature)) return { valid: false, error: 'missing_signature', ...base }; | ||
| if (!isString(receipt.verification_key)) return { valid: false, error: 'no_public_key', ...base }; | ||
| if (!HEX(receipt.signature) || !HEX(receipt.verification_key)) { | ||
| return { valid: false, error: 'malformed_hex', ...base, detail: 'signature and verification_key must be hex' }; | ||
| } | ||
| const pinned = isString(opts.publicKey); | ||
| if (pinned && opts.publicKey.toLowerCase() !== receipt.verification_key.toLowerCase()) { | ||
| return { valid: false, error: 'key_mismatch', ...base, publicKey: receipt.verification_key, expectedKey: opts.publicKey }; | ||
| } | ||
| const payload = legateReceiptPayload(receipt); | ||
| let ok = false; | ||
| try { | ||
| ok = ed25519.verify(hexToBytes(receipt.signature), utf8ToBytes(payload), hexToBytes(receipt.verification_key)); | ||
| } catch (e) { | ||
| return { valid: false, error: 'malformed_hex', ...base, detail: e.message, publicKey: receipt.verification_key }; | ||
| } | ||
| if (!ok) { | ||
| return { valid: false, error: 'invalid_signature', ...base, publicKey: receipt.verification_key, signedPayload: payload }; | ||
| } | ||
| return { | ||
| valid: true, | ||
| ...base, | ||
| publicKey: receipt.verification_key, | ||
| keySource: pinned ? 'embedded-receipt (pinned via --key)' : 'embedded-receipt', | ||
| signedPayload: payload, | ||
| proves: GOVERNED_PROVES, | ||
| limitations: GOVERNED_LIMITATIONS, | ||
| }; | ||
| } |
| /** | ||
| * Legate adherence / restraint proof pack verifier. | ||
| * | ||
| * A proof pack (type "scopeblind.legate.proof-pack.v1") is the allocator-facing, | ||
| * POSITION-BLIND record a Legate desk produces: what the gate prevented over a | ||
| * session (held / blocked, attributed to rules), what the order-path shadow would | ||
| * have blocked, and the digests it is bound to (committed mandate, signed book | ||
| * provenance, receipt Merkle root). It carries its own runtime verification key, so | ||
| * a third party re-verifies it offline with nothing but this CLI. | ||
| * | ||
| * Signature recipe (legate-proof-pack.cjs signProofPack): Ed25519 over the UTF-8 | ||
| * bytes of the canonical (deep-sorted, no-whitespace) JSON of the pack MINUS its | ||
| * `signature`, `sha256`, and `hybrid_signature` fields; `sha256` is that canonical's | ||
| * SHA-256 digest. The runtime public key is `verification_key` (and runtime. | ||
| * verification_key), raw 32-byte hex. Signing is over the bytes directly, like the | ||
| * Legate governed receipt, NOT over a pre-hash like the Gate tuple. | ||
| * | ||
| * @module verify-cli/src/engines/legate-proof-pack | ||
| */ | ||
| import { ed25519 } from '@noble/curves/ed25519'; | ||
| import { sha256 } from '@noble/hashes/sha256'; | ||
| import { utf8ToBytes } from '@noble/hashes/utils'; | ||
| import { canonicalize } from '../util/canonical.js'; | ||
| import { hexToBytes, bytesToHex } from '../util/hex.js'; | ||
| export const PROOF_PACK_TYPE = 'scopeblind.legate.proof-pack.v1'; | ||
| const isString = (v) => typeof v === 'string' && v.length > 0; | ||
| const HEX = (s) => typeof s === 'string' && /^[0-9a-f]+$/i.test(s) && s.length % 2 === 0; | ||
| const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0); | ||
| const PROVES = [ | ||
| 'The pack was signed by the holder of the runtime key (verification_key) and not altered since: every field is bound by the Ed25519 signature over the canonical bytes.', | ||
| 'The restraint counts (held / blocked, by rule) and the order-path shadow counts are the runtime\'s own, attributed to the committed mandate digest.', | ||
| 'The artifact is position-blind: it contains digests and counts only, never positions, so adherence is verifiable without disclosing the book.', | ||
| ]; | ||
| const LIMITATIONS = [ | ||
| 'That the underlying receipts behind the Merkle root and the signed book behind book.sha256 are themselves correct, unless those are separately verified.', | ||
| 'That the runtime key belongs to the desk you expect; pin it with --key to bind the pack to a known runtime.', | ||
| ]; | ||
| /** | ||
| * Reconstruct the exact canonical string that was signed: the pack minus the | ||
| * signature, the bound digest, and any hybrid signature. | ||
| */ | ||
| export function proofPackSignedCanonical(pack) { | ||
| const { signature, sha256: _digest, hybrid_signature, ...rest } = pack; | ||
| return canonicalize(rest); | ||
| } | ||
| /** | ||
| * @param {object} pack the proof pack envelope | ||
| * @param {object} opts { publicKey?: pinned runtime key (hex) } | ||
| */ | ||
| export function verifyLegateProofPack(pack, opts = {}) { | ||
| const base = { | ||
| format: 'legate-proof-pack', | ||
| schema: isString(pack?.type) ? pack.type : PROOF_PACK_TYPE, | ||
| algorithm: 'ed25519', | ||
| }; | ||
| if (pack === null || typeof pack !== 'object' || Array.isArray(pack)) { | ||
| return { valid: false, error: 'unknown_format', ...base, detail: 'proof pack is not an object' }; | ||
| } | ||
| if (pack.type !== PROOF_PACK_TYPE) { | ||
| return { valid: false, error: 'unknown_format', ...base, detail: `type is not ${PROOF_PACK_TYPE}` }; | ||
| } | ||
| const vk = isString(pack.verification_key) | ||
| ? pack.verification_key | ||
| : (pack.runtime && isString(pack.runtime.verification_key) ? pack.runtime.verification_key : undefined); | ||
| if (!isString(pack.signature)) return { valid: false, error: 'missing_signature', ...base }; | ||
| if (!isString(vk)) return { valid: false, error: 'no_public_key', ...base }; | ||
| if (!HEX(pack.signature) || !HEX(vk)) { | ||
| return { valid: false, error: 'malformed_hex', ...base, detail: 'signature and verification_key must be hex' }; | ||
| } | ||
| const pinned = isString(opts.publicKey); | ||
| if (pinned && opts.publicKey.toLowerCase() !== vk.toLowerCase()) { | ||
| return { valid: false, error: 'key_mismatch', ...base, publicKey: vk, expectedKey: opts.publicKey }; | ||
| } | ||
| // The bound digest must match the canonical the signature covers. | ||
| const canonical = proofPackSignedCanonical(pack); | ||
| if (isString(pack.sha256)) { | ||
| const recomputed = bytesToHex(sha256(utf8ToBytes(canonical))); | ||
| if (recomputed !== pack.sha256) { | ||
| return { valid: false, error: 'digest_mismatch', ...base, detail: 'sha256 does not match the canonical bytes (the pack was modified after signing)', publicKey: vk }; | ||
| } | ||
| } | ||
| let ok = false; | ||
| try { | ||
| ok = ed25519.verify(hexToBytes(pack.signature), utf8ToBytes(canonical), hexToBytes(vk)); | ||
| } catch (e) { | ||
| return { valid: false, error: 'malformed_hex', ...base, detail: e.message, publicKey: vk }; | ||
| } | ||
| if (!ok) { | ||
| return { valid: false, error: 'invalid_signature', ...base, publicKey: vk }; | ||
| } | ||
| const mandate = pack.mandate || {}; | ||
| const restraint = pack.restraint || {}; | ||
| const shadow = pack.shadow || null; | ||
| const book = pack.book || {}; | ||
| return { | ||
| valid: true, | ||
| ...base, | ||
| publicKey: vk, | ||
| keySource: pinned ? 'embedded-pack (pinned via --key)' : 'embedded-pack', | ||
| signerKid: isString(pack.signer_kid) ? pack.signer_kid : undefined, | ||
| generatedAt: isString(pack.generated_at) ? pack.generated_at : undefined, | ||
| positionBlind: pack.position_blind !== false, | ||
| mandate: { name: mandate.name, sha256: mandate.sha256, mode: mandate.mode, ruleCount: num(mandate.rule_count) }, | ||
| restraint: { blocked: num(restraint.blocked), held: num(restraint.held), byRule: Array.isArray(restraint.by_rule) ? restraint.by_rule : [] }, | ||
| shadow: shadow ? { observed: num(shadow.observed), wouldBlock: num(shadow.would_block), wouldHold: num(shadow.would_hold) } : null, | ||
| bookDigest: isString(book.sha256) ? book.sha256 : null, | ||
| sessionMerkleRoot: isString(pack.session_merkle_root) ? pack.session_merkle_root : null, | ||
| receiptCount: num(pack.receipt_count), | ||
| // A hybrid post-quantum signature, when present, is recognized and reported. | ||
| // Classical Ed25519 is verified here; ML-DSA-65 verification is an optional add-on | ||
| // (the runtime is dependency-free and signs classically; PQ is documented in the | ||
| // restraint-receipts draft as an optional field for surfaces that can produce it). | ||
| hybridSignaturePresent: Boolean(pack.hybrid_signature), | ||
| proves: PROVES, | ||
| limitations: LIMITATIONS, | ||
| }; | ||
| } |
| /** | ||
| * ScopeBlind macro-engine snapshot and track-record verifier. | ||
| * | ||
| * The macro engine signs its snapshots (market-state, regime, tape, | ||
| * vulnerability, alert, journal) and its exported track-record bundle with | ||
| * the EXACT same tuple format the Gate uses: { payload, digest, signature, | ||
| * verification_key }, where digest = sha256(canonicalGateJSON(payload)) and | ||
| * signature = Ed25519 over the bytes of the hex digest. | ||
| * | ||
| * These tuples already verify cryptographically through | ||
| * engines/gate-receipt.js verifyGateTuple. This module ADDS: | ||
| * - recognition of the scopeblind.macro.* schemas, | ||
| * - a semantic-contract check per schema (macroSchemaErrors), | ||
| * - a schema-aware salient-field summary for display (macroSummary), | ||
| * - a track-record BUNDLE verifier (verifyMacroTrackRecord) that mirrors | ||
| * verifyGateBundle: every record's signature, single-signer custody, an | ||
| * exact manifest inventory, count checks, and the history_head_digest. | ||
| * | ||
| * No new cryptography is introduced. Crypto is delegated to verifyGateTuple | ||
| * and canonicalization to canonicalGateJSON, both from gate-receipt.js. | ||
| * | ||
| * @module verify-cli/src/engines/macro-snapshot | ||
| * @license Apache-2.0 | ||
| */ | ||
| import { sha256 } from '@noble/hashes/sha256'; | ||
| import { utf8ToBytes, bytesToHex } from '@noble/hashes/utils'; | ||
| import { canonicalGateJSON, verifyGateTuple } from './gate-receipt.js'; | ||
| export const MACRO_BUNDLE_SCHEMA = 'scopeblind.macro.track-record-bundle/1'; | ||
| export const MACRO_MANIFEST_SCHEMA = 'scopeblind.macro.track-record-manifest/1'; | ||
| export const MACRO_ANCHOR_SCHEMA = 'scopeblind.macro.track-record-anchor/1'; | ||
| export const MACRO_TRANSPARENCY_HEAD_SCHEMA = 'scopeblind.macro.transparency-head/1'; | ||
| export const MACRO_TRANSPARENCY_WITNESS_SCHEMA = 'scopeblind.macro.transparency-witness/1'; | ||
| export const MACRO_SCHEMA_PREFIX = 'scopeblind.macro.'; | ||
| /** Human descriptions for every recognized macro schema. */ | ||
| export const MACRO_SCHEMAS = { | ||
| 'scopeblind.macro.market-state/1': 'daily cross-asset market-state classification', | ||
| 'scopeblind.macro.regime-snapshot/1': 'macro regime snapshot from economic series', | ||
| 'scopeblind.macro.tape-snapshot/1': 'intraday/session tape attribution snapshot', | ||
| 'scopeblind.macro.vulnerability/1': 'book vulnerability decision aid', | ||
| 'scopeblind.macro.alert/1': 'state-change alert', | ||
| 'scopeblind.macro.journal-entry/1': 'model journal entry referencing snapshots', | ||
| 'scopeblind.macro.price-snapshot/1': 'signed point-in-time price levels per risk factor', | ||
| [MACRO_MANIFEST_SCHEMA]: 'signed track-record export manifest', | ||
| [MACRO_ANCHOR_SCHEMA]: 'signed append-only track-record checkpoint', | ||
| [MACRO_TRANSPARENCY_HEAD_SCHEMA]: 'signed RFC 6962 transparency-log head over snapshot digests', | ||
| [MACRO_TRANSPARENCY_WITNESS_SCHEMA]: 'independent witness co-signature over a transparency-log head', | ||
| }; | ||
| const HEX_64 = /^[0-9a-f]{64}$/; | ||
| const MARKET_STATE_CLASSES = new Set(['risk_on', 'constructive', 'mixed', 'deteriorating', 'stress']); | ||
| const MARKET_STATE_PILLARS = ['trend', 'breadth', 'liquidity', 'credit', 'volatility']; | ||
| const REGIMES = new Set(['goldilocks', 'reflation', 'stagflation', 'deflation_bust', 'liquidity_led_recovery', 'tightening_squeeze', 'transitional']); | ||
| const TAPE_TYPES = new Set(['credit_stress', 'tightening_shock', 'growth_scare', 'inflation_shock', 'liquidity_risk_on', 'reflation_risk_on', 'mixed']); | ||
| const ALERT_SEVERITIES = new Set(['info', 'action', 'critical']); | ||
| const ALERT_KINDS = new Set(['regime_transition', 'market_state_change', 'tape_type_change']); | ||
| const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v); | ||
| const isString = (v) => typeof v === 'string' && v.length > 0; | ||
| const isNumber = (v) => typeof v === 'number' && Number.isFinite(v); | ||
| const isInteger = (v) => Number.isInteger(v); | ||
| const isArray = (v) => Array.isArray(v); | ||
| /** Whether a schema string is a recognized macro snapshot/journal/manifest schema. */ | ||
| export function isMacroSchema(schema) { | ||
| return typeof schema === 'string' && schema.startsWith(MACRO_SCHEMA_PREFIX); | ||
| } | ||
| /** A market-state/regime/tape/etc snapshot proves authenticity + integrity + schema validity. */ | ||
| export const MACRO_PROVES = [ | ||
| 'Signature integrity: the snapshot verifies under its carried Ed25519 key.', | ||
| 'Integrity: neither payload nor digest has been modified since signing.', | ||
| 'Schema validity: the recognized macro snapshot satisfies its required semantic contract.', | ||
| ]; | ||
| export const MACRO_LIMITATIONS = [ | ||
| 'The real-world identity controlling the carried key unless the expected key is independently pinned with --key.', | ||
| 'Correctness of the underlying market or economic series the snapshot was computed from.', | ||
| 'That the classification, regime, or tape attribution is a good or predictive read of the market.', | ||
| 'Independent corroboration of the inputs unless separately attested; the model signs what it computed.', | ||
| ]; | ||
| export const MACRO_BUNDLE_PROVES = [ | ||
| 'Signature integrity: every included snapshot, journal entry, manifest, and anchor verifies under one Ed25519 key.', | ||
| 'Integrity: no record payload or digest has been modified since signing.', | ||
| 'Single-key consistency: all records, including the manifest, share one model verification key.', | ||
| 'Manifest completeness: the signed manifest exactly enumerates every exported snapshot and journal entry, in order.', | ||
| 'History binding: the manifest history_head_digest commits to the exact ordered set of record digests.', | ||
| ]; | ||
| export const MACRO_BUNDLE_LIMITATIONS = [ | ||
| 'Correctness of the underlying market or economic data the snapshots were computed from.', | ||
| 'That the recorded calls were good predictions; the bundle is a tamper-evident track record, not a performance claim.', | ||
| 'The real-world identity controlling an embedded key unless the expected key is independently pinned with --key.', | ||
| 'Global history completeness unless prior manifests are chained and the latest history/anchor head is independently retained or timestamped.', | ||
| ]; | ||
| function requireFields(payload, fields, errors) { | ||
| for (const field of fields) { | ||
| if (payload[field] === undefined || payload[field] === null || payload[field] === '') errors.push(`missing ${field}`); | ||
| } | ||
| } | ||
| /** Validate an array of { schema, as_of, digest } reference objects with 64-hex digests. */ | ||
| function refErrors(refs, label, errors, { nonEmpty = true } = {}) { | ||
| if (!isArray(refs)) { errors.push(`${label} must be an array`); return; } | ||
| if (nonEmpty && refs.length === 0) { errors.push(`${label} must be non-empty`); return; } | ||
| for (const ref of refs) { | ||
| if (!isObject(ref) || !isString(ref.schema) || !isString(ref.as_of) || !HEX_64.test(ref.digest || '')) { | ||
| errors.push(`${label} entry must be { schema, as_of, digest(64-hex) }`); | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Semantic-contract validation for a recognized macro payload. | ||
| * Returns [] for valid payloads and for unrecognized (non-macro or unknown | ||
| * macro) schemas; returns one or more human-readable error strings otherwise. | ||
| * | ||
| * @param {Object} payload signed macro payload | ||
| * @returns {string[]} | ||
| */ | ||
| export function macroSchemaErrors(payload) { | ||
| const errors = []; | ||
| if (!isObject(payload)) return errors; | ||
| const schema = payload.schema; | ||
| if (!Object.hasOwn(MACRO_SCHEMAS, schema)) return errors; | ||
| if (schema === 'scopeblind.macro.market-state/1') { | ||
| requireFields(payload, ['schema', 'engine_version', 'as_of', 'universe_digest', 'inputs_digest', 'pillars', 'evidence', 'classification', 'confidence', 'would_change', 'notes'], errors); | ||
| if (!MARKET_STATE_CLASSES.has(payload.classification)) errors.push('invalid classification'); | ||
| if (!isObject(payload.pillars)) errors.push('pillars must be an object'); | ||
| else { | ||
| for (const p of MARKET_STATE_PILLARS) { | ||
| if (!isInteger(payload.pillars[p]) || payload.pillars[p] < -2 || payload.pillars[p] > 2) errors.push(`invalid pillar ${p} (integer in [-2,2])`); | ||
| } | ||
| } | ||
| if (!isNumber(payload.confidence) || payload.confidence < 0 || payload.confidence > 1) errors.push('confidence must be a number in [0,1]'); | ||
| } else if (schema === 'scopeblind.macro.regime-snapshot/1') { | ||
| requireFields(payload, ['schema', 'engine_version', 'as_of', 'pillars', 'inputs', 'vintage_digest', 'candidate_regime', 'regime', 'liquidity_overlay', 'hysteresis', 'playbook', 'confidence', 'would_change', 'notes'], errors); | ||
| if (!REGIMES.has(payload.regime)) errors.push('invalid regime'); | ||
| if (!REGIMES.has(payload.candidate_regime)) errors.push('invalid candidate_regime'); | ||
| if (!HEX_64.test(payload.vintage_digest || '')) errors.push('invalid vintage_digest'); | ||
| if (!isObject(payload.pillars)) errors.push('pillars must be an object'); | ||
| if (!isArray(payload.inputs)) errors.push('inputs must be an array'); | ||
| if (!isNumber(payload.confidence) || payload.confidence < 0 || payload.confidence > 1) errors.push('confidence must be a number in [0,1]'); | ||
| } else if (schema === 'scopeblind.macro.tape-snapshot/1') { | ||
| requireFields(payload, ['schema', 'engine_version', 'as_of', 'session', 'tape_type', 'coherence', 'material', 'signals', 'unavailable', 'untestable_types', 'attribution', 'inputs_digest', 'would_change', 'notes'], errors); | ||
| if (!TAPE_TYPES.has(payload.tape_type)) errors.push('invalid tape_type'); | ||
| if (!isNumber(payload.coherence) || payload.coherence < 0 || payload.coherence > 1) errors.push('coherence must be a number in [0,1]'); | ||
| if (typeof payload.material !== 'boolean') errors.push('material must be a boolean'); | ||
| if (!isArray(payload.signals)) errors.push('signals must be an array'); | ||
| if (!isObject(payload.attribution) || !isString(payload.attribution.tier)) errors.push('attribution must carry a tier'); | ||
| } else if (schema === 'scopeblind.macro.vulnerability/1') { | ||
| requireFields(payload, ['schema', 'engine_version', 'as_of', 'posture', 'factor_exposures', 'betas', 'vulnerabilities', 'inputs_digest', 'would_change', 'notes'], errors); | ||
| if (!isObject(payload.posture)) errors.push('posture must be an object'); | ||
| if (!isArray(payload.factor_exposures)) errors.push('factor_exposures must be an array'); | ||
| if (!isArray(payload.betas)) errors.push('betas must be an array'); | ||
| if (!isArray(payload.vulnerabilities)) errors.push('vulnerabilities must be an array'); | ||
| } else if (schema === 'scopeblind.macro.alert/1') { | ||
| requireFields(payload, ['schema', 'engine_version', 'as_of', 'alert_id', 'kind', 'severity', 'title', 'detail', 'refs', 'budget'], errors); | ||
| if (!HEX_64.test(payload.alert_id || '')) errors.push('invalid alert_id'); | ||
| if (!ALERT_SEVERITIES.has(payload.severity)) errors.push('invalid severity'); | ||
| if (!ALERT_KINDS.has(payload.kind)) errors.push('invalid kind'); | ||
| refErrors(payload.refs, 'refs', errors); | ||
| } else if (schema === 'scopeblind.macro.journal-entry/1') { | ||
| requireFields(payload, ['schema', 'engine_version', 'as_of', 'author', 'note', 'references', 'tags'], errors); | ||
| if (!isArray(payload.tags)) errors.push('tags must be an array'); | ||
| refErrors(payload.references, 'references', errors); | ||
| } else if (schema === 'scopeblind.macro.price-snapshot/1') { | ||
| requireFields(payload, ['schema', 'engine_version', 'as_of', 'source', 'delay_minutes', 'levels', 'coverage', 'missing', 'notes'], errors); | ||
| if (!isInteger(payload.delay_minutes) || payload.delay_minutes < 0) errors.push('delay_minutes must be a non-negative integer'); | ||
| if (!isString(payload.source)) errors.push('source must be a non-empty string'); | ||
| if (!isArray(payload.coverage)) errors.push('coverage must be an array'); | ||
| if (!isArray(payload.missing)) errors.push('missing must be an array'); | ||
| if (!isObject(payload.levels)) { | ||
| errors.push('levels must be an object'); | ||
| } else { | ||
| const factors = Object.keys(payload.levels); | ||
| if (factors.length === 0) errors.push('levels must price at least one factor'); | ||
| const VALID_UNITS = new Set(['price', 'yield_pct', 'fx_rate', 'spread_bp']); | ||
| for (const f of factors) { | ||
| const lv = payload.levels[f]; | ||
| if (!isObject(lv)) { errors.push(`level ${f} must be an object`); continue; } | ||
| if (!isNumber(lv.level)) errors.push(`level ${f} must carry a numeric level`); | ||
| if (!isString(lv.instrument)) errors.push(`level ${f} must name an instrument`); | ||
| if (!VALID_UNITS.has(lv.unit)) errors.push(`level ${f} has invalid unit`); | ||
| if (lv.multiplier !== undefined && (!isNumber(lv.multiplier) || lv.multiplier <= 0)) errors.push(`level ${f} multiplier must be a positive number`); | ||
| } | ||
| // coverage must list exactly the priced factors (sorted), never overstate. | ||
| if (isArray(payload.coverage)) { | ||
| const cov = [...payload.coverage].sort(); | ||
| const keys = [...factors].sort(); | ||
| if (cov.length !== keys.length || cov.some((c, i) => c !== keys[i])) errors.push('coverage must list exactly the priced factors'); | ||
| } | ||
| } | ||
| } else if (schema === MACRO_MANIFEST_SCHEMA) { | ||
| requireFields(payload, ['schema', 'engine_version', 'exported_at', 'model_verification_key', 'period', 'entries', 'snapshot_count', 'journal_count', 'history_head_digest'], errors); | ||
| if (!HEX_64.test(payload.model_verification_key || '')) errors.push('invalid model_verification_key'); | ||
| if (!HEX_64.test(payload.history_head_digest || '')) errors.push('invalid history_head_digest'); | ||
| refErrors(payload.entries, 'entries', errors, { nonEmpty: false }); | ||
| if (payload.sequence !== undefined && (!isInteger(payload.sequence) || payload.sequence < 1)) errors.push('sequence must be a positive integer'); | ||
| } else if (schema === MACRO_ANCHOR_SCHEMA) { | ||
| requireFields(payload, ['schema', 'engine_version', 'anchored_at', 'sequence', 'manifest_digest', 'history_head_digest'], errors); | ||
| if (!isInteger(payload.sequence) || payload.sequence < 1) errors.push('sequence must be a positive integer'); | ||
| if (!HEX_64.test(payload.manifest_digest || '')) errors.push('invalid manifest_digest'); | ||
| if (!HEX_64.test(payload.history_head_digest || '')) errors.push('invalid history_head_digest'); | ||
| if (payload.previous_anchor_digest !== null && !HEX_64.test(payload.previous_anchor_digest || '')) errors.push('invalid previous_anchor_digest'); | ||
| } else if (schema === MACRO_TRANSPARENCY_HEAD_SCHEMA) { | ||
| requireFields(payload, ['schema', 'engine_version', 'log_id', 'tree_size', 'root_hash', 'timestamp'], errors); | ||
| if (!isInteger(payload.tree_size) || payload.tree_size < 0) errors.push('tree_size must be a non-negative integer'); | ||
| if (!HEX_64.test(payload.root_hash || '')) errors.push('invalid root_hash'); | ||
| // previous_root_hash is a required key but is null at the genesis head. | ||
| if (!Object.hasOwn(payload, 'previous_root_hash')) errors.push('missing previous_root_hash'); | ||
| else if (payload.previous_root_hash !== null && !HEX_64.test(payload.previous_root_hash || '')) errors.push('invalid previous_root_hash'); | ||
| } else if (schema === MACRO_TRANSPARENCY_WITNESS_SCHEMA) { | ||
| requireFields(payload, ['schema', 'engine_version', 'head_digest', 'root_hash', 'tree_size', 'witnessed_at', 'note'], errors); | ||
| if (!HEX_64.test(payload.head_digest || '')) errors.push('invalid head_digest'); | ||
| if (!HEX_64.test(payload.root_hash || '')) errors.push('invalid root_hash'); | ||
| if (!isInteger(payload.tree_size) || payload.tree_size < 0) errors.push('tree_size must be a non-negative integer'); | ||
| } | ||
| return errors; | ||
| } | ||
| /** | ||
| * Extract the salient display fields for a recognized macro snapshot. | ||
| * Returns null for unrecognized schemas. | ||
| * | ||
| * @param {Object} payload signed macro payload | ||
| * @returns {Object|null} | ||
| */ | ||
| export function macroSummary(payload) { | ||
| if (!isObject(payload) || !Object.hasOwn(MACRO_SCHEMAS, payload.schema)) return null; | ||
| const schema = payload.schema; | ||
| const base = { schema, description: MACRO_SCHEMAS[schema], as_of: payload.as_of }; | ||
| if (schema === 'scopeblind.macro.market-state/1') { | ||
| return { ...base, classification: payload.classification, pillars: payload.pillars, confidence: payload.confidence }; | ||
| } | ||
| if (schema === 'scopeblind.macro.regime-snapshot/1') { | ||
| return { ...base, regime: payload.regime, candidate_regime: payload.candidate_regime, liquidity_overlay: payload.liquidity_overlay, confidence: payload.confidence }; | ||
| } | ||
| if (schema === 'scopeblind.macro.tape-snapshot/1') { | ||
| return { ...base, tape_type: payload.tape_type, coherence: payload.coherence, material: payload.material, attribution_tier: payload.attribution?.tier }; | ||
| } | ||
| if (schema === 'scopeblind.macro.vulnerability/1') { | ||
| const top = (isArray(payload.vulnerabilities) ? payload.vulnerabilities : []) | ||
| .slice() | ||
| .sort((a, b) => (b?.pain ?? 0) - (a?.pain ?? 0)) | ||
| .slice(0, 3) | ||
| .map((v) => ({ factor: v?.factor, pain: v?.pain })); | ||
| return { ...base, regime: payload.posture?.regime, market_state: payload.posture?.market_state, top_vulnerabilities: top }; | ||
| } | ||
| if (schema === 'scopeblind.macro.alert/1') { | ||
| return { ...base, severity: payload.severity, kind: payload.kind, title: payload.title }; | ||
| } | ||
| if (schema === 'scopeblind.macro.journal-entry/1') { | ||
| return { ...base, author: payload.author, reference_count: isArray(payload.references) ? payload.references.length : 0, tags: payload.tags }; | ||
| } | ||
| if (schema === 'scopeblind.macro.price-snapshot/1') { | ||
| return { | ||
| ...base, | ||
| source: payload.source, | ||
| delay_minutes: payload.delay_minutes, | ||
| priced: isArray(payload.coverage) ? payload.coverage.length : 0, | ||
| missing: isArray(payload.missing) ? payload.missing.length : 0, | ||
| }; | ||
| } | ||
| if (schema === MACRO_MANIFEST_SCHEMA) { | ||
| return { ...base, sequence: payload.sequence ?? 1, snapshot_count: payload.snapshot_count, journal_count: payload.journal_count }; | ||
| } | ||
| if (schema === MACRO_ANCHOR_SCHEMA) { | ||
| return { ...base, sequence: payload.sequence, manifest_digest: payload.manifest_digest, history_head_digest: payload.history_head_digest }; | ||
| } | ||
| if (schema === MACRO_TRANSPARENCY_HEAD_SCHEMA) { | ||
| return { ...base, log_id: payload.log_id, tree_size: payload.tree_size, root_hash: payload.root_hash }; | ||
| } | ||
| if (schema === MACRO_TRANSPARENCY_WITNESS_SCHEMA) { | ||
| return { ...base, head_digest: payload.head_digest, tree_size: payload.tree_size }; | ||
| } | ||
| return base; | ||
| } | ||
| const refsEqual = (a, b) => | ||
| isObject(a) && isObject(b) && a.schema === b.schema && a.as_of === b.as_of && a.digest === b.digest; | ||
| // ── RFC 6962 transparency-log inclusion verification ───────────────── | ||
| // | ||
| // The macro engine signs an RFC 6962 Merkle transparency log over the | ||
| // snapshot/journal record digests. This block reconstructs a leaf's root from | ||
| // its audit path EXACTLY as the engine builds it, so the verifier can confirm | ||
| // that every exported record is committed under the signed head's root_hash. | ||
| // Hashing is domain-separated over RAW digest bytes (not the hex string). | ||
| /** Decode a hex string to a Uint8Array (no prefix handling; lengths are caller-checked). */ | ||
| function hexToBytes(hex) { | ||
| const bytes = new Uint8Array(hex.length / 2); | ||
| for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); | ||
| return bytes; | ||
| } | ||
| /** Concatenate Uint8Arrays into one. */ | ||
| function concatBytes(...arrays) { | ||
| let length = 0; | ||
| for (const a of arrays) length += a.length; | ||
| const out = new Uint8Array(length); | ||
| let offset = 0; | ||
| for (const a of arrays) { out.set(a, offset); offset += a.length; } | ||
| return out; | ||
| } | ||
| /** RFC 6962 leaf hash: sha256(0x00 || raw record-digest bytes). */ | ||
| function leafHash(recordDigestHex) { | ||
| return sha256(concatBytes(Uint8Array.of(0x00), hexToBytes(recordDigestHex))); | ||
| } | ||
| /** RFC 6962 interior node hash: sha256(0x01 || left || right). */ | ||
| function nodeHash(left, right) { | ||
| return sha256(concatBytes(Uint8Array.of(0x01), left, right)); | ||
| } | ||
| /** Largest power of two STRICTLY less than n (RFC 6962 split point). */ | ||
| function splitPoint(n) { | ||
| let k = 1; | ||
| while ((k << 1) < n) k <<= 1; | ||
| return k; | ||
| } | ||
| /** | ||
| * Verify an RFC 6962 inclusion proof: reconstruct the Merkle root for the leaf | ||
| * at proof.leaf_index in a tree of proof.tree_size leaves, consuming the audit | ||
| * path deepest-sibling-first (top sibling LAST, i.e. from the END of the array). | ||
| * | ||
| * @param {string} recordDigestHex 64-hex record digest committed as the leaf | ||
| * @param {Object} proof { leaf_index, tree_size, audit_path: string[] } | ||
| * @param {string} expectedRootHex 64-hex root_hash from the signed head | ||
| * @returns {boolean} | ||
| */ | ||
| export function verifyInclusion(recordDigestHex, proof, expectedRootHex) { | ||
| if (!isObject(proof)) return false; | ||
| const { leaf_index, tree_size, audit_path } = proof; | ||
| if (!isInteger(leaf_index) || !isInteger(tree_size)) return false; | ||
| if (leaf_index < 0 || leaf_index >= tree_size) return false; | ||
| if (!HEX_64.test(recordDigestHex || '')) return false; | ||
| if (!HEX_64.test(expectedRootHex || '')) return false; | ||
| if (!isArray(audit_path) || !audit_path.every((h) => HEX_64.test(h || ''))) return false; | ||
| const nodes = audit_path.map(hexToBytes); | ||
| // hi = number of path nodes still available; the top sibling is nodes[hi-1]. | ||
| function root(m, size, hi) { | ||
| if (size === 1) return hi === 0 ? leafHash(recordDigestHex) : null; | ||
| if (hi < 1) return null; | ||
| const k = splitPoint(size); | ||
| const sib = nodes[hi - 1]; | ||
| if (m < k) { | ||
| const left = root(m, k, hi - 1); | ||
| return left && nodeHash(left, sib); | ||
| } | ||
| const right = root(m - k, size - k, hi - 1); | ||
| return right && nodeHash(sib, right); | ||
| } | ||
| const r = root(leaf_index, tree_size, nodes.length); | ||
| return r !== null && bytesToHex(r) === expectedRootHex; | ||
| } | ||
| /** | ||
| * Verify a track-record bundle's transparency evidence: the signed log head, | ||
| * every record's inclusion under that head's root, and (optionally) an | ||
| * independent witness co-signature over the same head. | ||
| * | ||
| * @param {Object} evidence bundle.transparency: { head, witness, inclusions } | ||
| * @param {string[]} recordDigests every snapshot + journal record digest that | ||
| * must be committed in the log | ||
| * @returns {{ head_valid: boolean, all_included: boolean, witness_present: boolean, | ||
| * witness_independent: boolean, anchor: 'none'|'self_signed'|'witnessed', | ||
| * tree_size: number|null, root_hash: string|null, errors: string[] }} | ||
| */ | ||
| export function verifyTransparencyEvidence(evidence, recordDigests = []) { | ||
| const result = { | ||
| head_valid: false, | ||
| all_included: false, | ||
| witness_present: false, | ||
| witness_independent: false, | ||
| anchor: 'none', | ||
| tree_size: null, | ||
| root_hash: null, | ||
| errors: [], | ||
| }; | ||
| if (!isObject(evidence) || !isObject(evidence.head)) { | ||
| result.errors.push('transparency evidence is missing a signed head'); | ||
| return result; | ||
| } | ||
| const head = evidence.head; | ||
| const headResult = verifyGateTuple(head); | ||
| const headPayload = isObject(head.payload) ? head.payload : {}; | ||
| const root = headPayload.root_hash; | ||
| result.tree_size = isInteger(headPayload.tree_size) ? headPayload.tree_size : null; | ||
| result.root_hash = isString(root) ? root : null; | ||
| if (!headResult.valid) { | ||
| result.errors.push(`transparency head signature invalid: ${headResult.error}`); | ||
| } else if (headPayload.schema !== MACRO_TRANSPARENCY_HEAD_SCHEMA) { | ||
| result.errors.push('transparency head is not a transparency-head schema'); | ||
| } else if (!HEX_64.test(root || '')) { | ||
| result.errors.push('transparency head root_hash is not 64-hex'); | ||
| } else { | ||
| result.head_valid = true; | ||
| } | ||
| // Inclusion of every checked record under the signed root. | ||
| if (result.head_valid) { | ||
| const inclusions = isArray(evidence.inclusions) ? evidence.inclusions : []; | ||
| const byDigest = new Map(); | ||
| for (const inc of inclusions) { | ||
| if (isObject(inc) && isString(inc.digest)) byDigest.set(inc.digest.toLowerCase(), inc); | ||
| } | ||
| let allIncluded = recordDigests.length > 0 || inclusions.length > 0; | ||
| for (const digest of recordDigests) { | ||
| const inc = byDigest.get(String(digest).toLowerCase()); | ||
| if (!inc || !verifyInclusion(digest, inc.proof, root)) { | ||
| allIncluded = false; | ||
| result.errors.push(`record ${String(digest).slice(0, 16)}... is not provably included in the signed log`); | ||
| } | ||
| } | ||
| result.all_included = allIncluded; | ||
| } | ||
| // Optional independent witness co-signature over the same head. | ||
| if (isObject(evidence.witness)) { | ||
| result.witness_present = true; | ||
| const witness = evidence.witness; | ||
| const witnessResult = verifyGateTuple(witness); | ||
| const witnessPayload = isObject(witness.payload) ? witness.payload : {}; | ||
| const witnessValid = witnessResult.valid | ||
| && witnessPayload.schema === MACRO_TRANSPARENCY_WITNESS_SCHEMA | ||
| && witnessPayload.head_digest === head.digest | ||
| && witnessPayload.root_hash === root; | ||
| if (!witnessValid) { | ||
| result.errors.push('transparency witness does not co-sign this head'); | ||
| } else { | ||
| result.witness_independent = isString(witness.verification_key) | ||
| && isString(head.verification_key) | ||
| && witness.verification_key.toLowerCase() !== head.verification_key.toLowerCase(); | ||
| } | ||
| } | ||
| result.anchor = (!result.head_valid || !result.all_included) | ||
| ? 'none' | ||
| : result.witness_independent ? 'witnessed' : 'self_signed'; | ||
| return result; | ||
| } | ||
| /** | ||
| * Verify a macro track-record bundle. | ||
| * | ||
| * Mirrors verifyGateBundle: it checks each record's signature, that every | ||
| * record (including the manifest) is signed by the single declared model | ||
| * key, that the manifest entries exactly enumerate the snapshots followed by | ||
| * the journal entries in order, the snapshot/journal counts, and that the | ||
| * history_head_digest recomputes over the ordered record digests. | ||
| * | ||
| * @param {Object} bundle parsed track-record bundle | ||
| * @param {Object} [opts] { publicKey } optional pinned model key | ||
| * @returns {Object} result with the same error-shape conventions as gate-receipt.js | ||
| */ | ||
| export function verifyMacroTrackRecord(bundle, opts = {}) { | ||
| const results = { | ||
| valid: true, | ||
| format: 'macro-track-record', | ||
| schema: bundle?.schema, | ||
| exportedAt: bundle?.exported_at, | ||
| period: isObject(bundle?.period) ? bundle.period : null, | ||
| custody: isString(bundle?.custody) ? bundle.custody : null, | ||
| modelVerificationKey: isString(bundle?.model_verification_key) ? bundle.model_verification_key : null, | ||
| snapshotCount: 0, | ||
| journalCount: 0, | ||
| total: 0, | ||
| passed: 0, | ||
| failed: 0, | ||
| cryptoFailed: 0, | ||
| chainChecks: 0, | ||
| chainFailed: 0, | ||
| errors: [], | ||
| records: [], | ||
| signers: [], | ||
| singleSigner: true, | ||
| manifestValid: null, | ||
| signerPinned: isString(opts.publicKey), | ||
| identityStatus: isString(opts.publicKey) ? 'pinned_operator_key' : 'embedded_key_only', | ||
| historyChainValid: null, | ||
| historyAnchored: false, | ||
| historyHeadPinned: false, | ||
| anchorHeadPinned: false, | ||
| sequence: 1, | ||
| transparencyAnchor: null, | ||
| proves: MACRO_BUNDLE_PROVES, | ||
| limitations: MACRO_BUNDLE_LIMITATIONS, | ||
| }; | ||
| if (!isObject(bundle) || bundle.schema !== MACRO_BUNDLE_SCHEMA || !isArray(bundle.snapshots) || !isObject(bundle.manifest)) { | ||
| return { ...results, valid: false, error: 'unknown_format', detail: 'unsupported macro bundle schema or missing snapshots/manifest' }; | ||
| } | ||
| const snapshots = bundle.snapshots; | ||
| const journal = isArray(bundle.journal) ? bundle.journal : []; | ||
| const priorManifests = isArray(bundle.prior_manifests) ? bundle.prior_manifests : []; | ||
| const anchors = isArray(bundle.anchor_chain) ? bundle.anchor_chain : []; | ||
| results.snapshotCount = snapshots.length; | ||
| results.journalCount = journal.length; | ||
| if (!HEX_64.test(results.modelVerificationKey || '')) { | ||
| return { ...results, valid: false, error: 'no_public_key', detail: 'bundle requires model_verification_key' }; | ||
| } | ||
| const modelKey = results.modelVerificationKey.toLowerCase(); | ||
| if (isString(opts.publicKey) && opts.publicKey.toLowerCase() !== modelKey) { | ||
| return { ...results, valid: false, error: 'key_mismatch', detail: 'bundle model_verification_key does not match --key' }; | ||
| } | ||
| const signerRoles = new Map(); | ||
| let firstError = null; | ||
| const add = (tuple, role, index) => { | ||
| results.total++; | ||
| const r = verifyGateTuple(tuple, { publicKey: modelKey }); | ||
| const key = isString(tuple?.verification_key) ? tuple.verification_key.toLowerCase() : null; | ||
| if (key) { | ||
| if (!signerRoles.has(key)) signerRoles.set(key, new Set()); | ||
| signerRoles.get(key).add(role); | ||
| if (key !== modelKey) results.singleSigner = false; | ||
| } else { | ||
| results.singleSigner = false; | ||
| } | ||
| const record = { | ||
| index: index + 1, | ||
| role, | ||
| schema: r.schema, | ||
| schemaRecognized: r.schemaRecognized || isMacroSchema(r.schema), | ||
| cryptoValid: r.valid, | ||
| cryptoError: r.valid ? undefined : r.error, | ||
| signer: tuple?.verification_key, | ||
| digest: tuple?.digest, | ||
| }; | ||
| results.records.push(record); | ||
| if (!r.valid) { | ||
| results.cryptoFailed++; | ||
| results.valid = false; | ||
| firstError ||= r.error; | ||
| results.errors.push(`[crypto] ${role} ${index + 1}: ${r.error}${r.detail ? ` (${r.detail})` : ''}`); | ||
| results.failed++; | ||
| } else { | ||
| results.passed++; | ||
| } | ||
| return r; | ||
| }; | ||
| for (let i = 0; i < snapshots.length; i++) add(snapshots[i], 'snapshot', i); | ||
| for (let i = 0; i < journal.length; i++) add(journal[i], 'journal', i); | ||
| for (let i = 0; i < priorManifests.length; i++) add(priorManifests[i], 'prior-manifest', i); | ||
| const mr = add(bundle.manifest, 'manifest', -1); | ||
| for (let i = 0; i < anchors.length; i++) add(anchors[i], 'history-anchor', i); | ||
| results.manifestValid = mr.valid; | ||
| // Single-signer custody check (every record, including the manifest, shares | ||
| // the declared model key). | ||
| results.chainChecks++; | ||
| if (!results.singleSigner) { | ||
| results.valid = false; | ||
| results.chainFailed++; | ||
| results.errors.push('[chain] not all records are signed by model_verification_key (single-signer custody violated)'); | ||
| } | ||
| // Manifest inventory + counts + history head. | ||
| if (mr.valid) { | ||
| const p = bundle.manifest.payload; | ||
| const ordered = [...snapshots, ...journal]; | ||
| const expectedEntries = ordered.map((t) => ({ schema: t?.payload?.schema, as_of: t?.payload?.as_of, digest: t?.digest })); | ||
| const historyHead = bytesToHex(sha256(utf8ToBytes(canonicalGateJSON(ordered.map((t) => t?.digest))))); | ||
| results.chainChecks++; | ||
| const entries = isArray(p.entries) ? p.entries : []; | ||
| let manifestError = null; | ||
| if (p.model_verification_key?.toLowerCase() !== modelKey) manifestError = 'manifest model_verification_key mismatch'; | ||
| else if (p.exported_at !== bundle.exported_at) manifestError = 'manifest exported_at mismatch'; | ||
| else if (entries.length !== expectedEntries.length) manifestError = `manifest entries count (${entries.length}) does not match exported records (${expectedEntries.length})`; | ||
| else if (!entries.every((e, idx) => refsEqual(e, expectedEntries[idx]))) manifestError = 'manifest entries do not exactly enumerate the exported records in order'; | ||
| else if (p.snapshot_count !== snapshots.length) manifestError = `manifest snapshot_count (${p.snapshot_count}) does not match (${snapshots.length})`; | ||
| else if (p.journal_count !== journal.length) manifestError = `manifest journal_count (${p.journal_count}) does not match (${journal.length})`; | ||
| else if (p.history_head_digest !== historyHead) manifestError = 'manifest history_head_digest mismatch'; | ||
| else if (p.sequence !== undefined && (!isInteger(p.sequence) || p.sequence < 1)) manifestError = 'manifest sequence is not a positive integer'; | ||
| if (manifestError) { | ||
| results.valid = false; | ||
| results.manifestValid = false; | ||
| results.chainFailed++; | ||
| results.errors.push(`[chain] Manifest: ${manifestError}`); | ||
| } | ||
| results.sequence = p.sequence ?? 1; | ||
| results.chainChecks++; | ||
| if (isString(opts.historyHead)) { | ||
| results.historyHeadPinned = opts.historyHead.toLowerCase() === String(p.history_head_digest).toLowerCase(); | ||
| if (!results.historyHeadPinned) { | ||
| results.valid = false; | ||
| results.chainFailed++; | ||
| results.errors.push('[chain] current history head does not match --history-head'); | ||
| } | ||
| } | ||
| const manifests = [...priorManifests, bundle.manifest]; | ||
| let historyChainError = null; | ||
| if (priorManifests.length > 0 || p.sequence !== undefined) { | ||
| const retained = new Set(expectedEntries.map((entry) => entry.digest)); | ||
| for (let i = 0; i < manifests.length && !historyChainError; i++) { | ||
| const current = manifests[i]; | ||
| const payload = current?.payload; | ||
| const expectedSequence = i + 1; | ||
| if ((payload?.sequence ?? expectedSequence) !== expectedSequence) { | ||
| historyChainError = `manifest sequence mismatch at ${expectedSequence}`; | ||
| break; | ||
| } | ||
| if (i > 0) { | ||
| const previous = manifests[i - 1]; | ||
| if (payload?.previous_manifest_digest !== previous?.digest | ||
| || payload?.previous_history_head_digest !== previous?.payload?.history_head_digest) { | ||
| historyChainError = `manifest previous-head mismatch at sequence ${expectedSequence}`; | ||
| break; | ||
| } | ||
| } | ||
| for (const entry of (isArray(payload?.entries) ? payload.entries : [])) { | ||
| if (!retained.has(entry.digest)) { | ||
| historyChainError = `current export omits prior record ${entry.digest}`; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| results.historyChainValid = historyChainError === null; | ||
| results.chainChecks++; | ||
| if (historyChainError) { | ||
| results.valid = false; | ||
| results.chainFailed++; | ||
| results.errors.push(`[chain] History: ${historyChainError}`); | ||
| } | ||
| } | ||
| if (anchors.length > 0) { | ||
| results.historyAnchored = true; | ||
| const offset = manifests.length - anchors.length; | ||
| let anchorError = null; | ||
| for (let i = 0; i < anchors.length && !anchorError; i++) { | ||
| const anchor = anchors[i]; | ||
| const anchorPayload = anchor?.payload; | ||
| const manifest = manifests[offset + i]; | ||
| if (!manifest) { | ||
| anchorError = `anchor ${i + 1} has no corresponding manifest`; | ||
| break; | ||
| } | ||
| const previousAnchor = i > 0 ? anchors[i - 1]?.digest : null; | ||
| if (anchorPayload?.schema !== MACRO_ANCHOR_SCHEMA | ||
| || anchorPayload?.sequence !== manifest.payload?.sequence | ||
| || anchorPayload?.manifest_digest !== manifest.digest | ||
| || anchorPayload?.history_head_digest !== manifest.payload?.history_head_digest | ||
| || anchorPayload?.previous_anchor_digest !== previousAnchor) { | ||
| anchorError = `anchor mismatch at sequence ${anchorPayload?.sequence ?? i + 1}`; | ||
| } | ||
| } | ||
| results.chainChecks++; | ||
| if (anchorError) { | ||
| results.valid = false; | ||
| results.chainFailed++; | ||
| results.errors.push(`[chain] Anchor: ${anchorError}`); | ||
| } | ||
| const anchorHead = anchors.at(-1)?.digest; | ||
| if (isString(opts.anchorHead)) { | ||
| results.anchorHeadPinned = opts.anchorHead.toLowerCase() === String(anchorHead).toLowerCase(); | ||
| results.chainChecks++; | ||
| if (!results.anchorHeadPinned) { | ||
| results.valid = false; | ||
| results.chainFailed++; | ||
| results.errors.push('[chain] current anchor head does not match --anchor-head'); | ||
| } | ||
| } | ||
| } | ||
| } else { | ||
| // Manifest signature failed; inventory cannot be trusted. | ||
| results.chainChecks++; | ||
| results.chainFailed++; | ||
| results.valid = false; | ||
| results.errors.push('[chain] Manifest signature invalid; inventory cannot be verified'); | ||
| } | ||
| // Optional RFC 6962 transparency evidence: confirm every exported record | ||
| // digest is committed under a signed (and optionally witnessed) log head. | ||
| // A bundle WITHOUT a transparency field stays valid (backward compatible). | ||
| if (isObject(bundle.transparency)) { | ||
| const recordDigests = [...snapshots, ...journal] | ||
| .map((t) => (isString(t?.digest) ? t.digest : null)) | ||
| .filter(Boolean); | ||
| const transparency = verifyTransparencyEvidence(bundle.transparency, recordDigests); | ||
| results.transparencyAnchor = transparency; | ||
| results.chainChecks++; | ||
| if (!transparency.head_valid || !transparency.all_included) { | ||
| results.valid = false; | ||
| results.chainFailed++; | ||
| const why = !transparency.head_valid | ||
| ? 'transparency head is not a valid signed log head' | ||
| : 'one or more exported records are not provably included in the signed log'; | ||
| results.errors.push(`[chain] Transparency: ${why}${transparency.errors.length ? ` (${transparency.errors[0]})` : ''}`); | ||
| } | ||
| } | ||
| results.signers = [...signerRoles.entries()].map(([key, roles]) => ({ key, roles: [...roles].sort(), isModelKey: key === modelKey })); | ||
| if (!results.valid) results.error = firstError || 'chain_link_mismatch'; | ||
| return results; | ||
| } |
| /** | ||
| * Trusted Context Pack verifier (TCB v1). | ||
| * | ||
| * A fund's desktop runtime turns an approved file (a positions export, a blotter, | ||
| * a mandate, a research folder) into a SIGNED TrustedContextPack before any agent | ||
| * or gate operates over it. This engine lets a third party (an allocator, an LP, | ||
| * an auditor) re-verify that pack offline, holding only this open tool and no | ||
| * ScopeBlind code: the digest is recomputed over the canonical payload and the | ||
| * Ed25519 signature is checked over that digest against the embedded (or pinned) | ||
| * key, exactly like a Gate receipt tuple. | ||
| * | ||
| * The TCB-specific check is the relabel guard: gate_status MUST equal the status | ||
| * derived from the signed confidence and freshness. A pack cannot be re-signed to | ||
| * claim a low-confidence or stale parse is `usable`. This is what makes the | ||
| * confidence-gating trustworthy to someone who did not produce the pack. | ||
| * | ||
| * What a verified pack proves: these exact bytes, with this file hash, parsed to | ||
| * this context at this confidence and freshness, signed by this key. What it does | ||
| * NOT prove: that the source file is authentic, complete, or the fund's true book. | ||
| * That requires a custodian-signed feed or a DKIM/PAdES source (a stronger tier). | ||
| * | ||
| * @module verify-cli/src/engines/trusted-context-pack | ||
| * @license Apache-2.0 | ||
| */ | ||
| import { ed25519 } from '@noble/curves/ed25519'; | ||
| import { sha256 } from '@noble/hashes/sha256'; | ||
| import { utf8ToBytes } from '@noble/hashes/utils'; | ||
| import { sortKeysDeep } from '../util/canonical.js'; | ||
| import { hexToBytes, bytesToHex } from '../util/hex.js'; | ||
| export const TCB_SCHEMA = 'scopeblind.trusted_context_pack.v1'; | ||
| const TCB_USABLE_THRESHOLD = 0.75; | ||
| const HEX_64 = /^[0-9a-f]{64}$/; | ||
| const SOURCE_TYPES = new Set(['book_positions', 'nav_account', 'blotter', 'risk_report', 'mandate_limits', 'research', 'operations', 'unknown']); | ||
| const GATE_STATUSES = new Set(['usable', 'needs_approval', 'blocked']); | ||
| const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v); | ||
| const isString = (v) => typeof v === 'string' && v.length > 0; | ||
| const isNumber = (v) => typeof v === 'number' && Number.isFinite(v); | ||
| export function canonicalTcbJSON(payload) { | ||
| return JSON.stringify(sortKeysDeep(payload)); | ||
| } | ||
| /** The single source of truth for the gate decision (kept in sync with | ||
| * scopeblind-pm/src/trusted-context.ts:gateStatusFor). */ | ||
| export function gateStatusFor(confidence, freshness) { | ||
| if (confidence <= 0) return 'blocked'; | ||
| if (confidence < TCB_USABLE_THRESHOLD || freshness?.stale === true) return 'needs_approval'; | ||
| return 'usable'; | ||
| } | ||
| export const TCB_PROVES = [ | ||
| 'Authenticity: the pack was signed by the Ed25519 key it carries (the fund runtime that built it).', | ||
| 'Integrity: the file hash, parsed context, confidence, freshness, and gate decision are bound by the signature and unmodified.', | ||
| 'Honest gating: gate_status matches the confidence and freshness, so a low-confidence or stale parse cannot be relabeled usable.', | ||
| ]; | ||
| export const TCB_LIMITATIONS = [ | ||
| 'That the source file is authentic, complete, or the fund\'s true book; a pack attests the parse, not the provenance.', | ||
| 'Correctness of the original file behind file_hash unless its bytes are separately disclosed and re-hashed.', | ||
| 'Whether the embedded verification_key is the runtime you expect; pin it with --key to bind trust to a known signer.', | ||
| 'A stronger source tier (custodian-signed feed, DKIM or PAdES) is needed to attest where the data came from.', | ||
| ]; | ||
| function collectFields(payload) { | ||
| const out = {}; | ||
| for (const k of ['source_type', 'source_format', 'source_lineage', 'file_name', 'file_hash', 'parser_version', 'confidence', 'gate_status', 'workspace_id', 'source_id']) { | ||
| if (payload[k] !== undefined && payload[k] !== null) out[k] = payload[k]; | ||
| } | ||
| if (isObject(payload.freshness)) out.freshness = { as_of: payload.freshness.as_of ?? null, stale: payload.freshness.stale === true }; | ||
| if (Array.isArray(payload.warnings)) out.warnings = payload.warnings; | ||
| if (isObject(payload.summary)) out.summary = payload.summary; | ||
| return out; | ||
| } | ||
| function schemaErrors(payload) { | ||
| const errors = []; | ||
| for (const f of ['workspace_id', 'source_id', 'source_format', 'file_hash', 'parser_version']) { | ||
| if (!isString(payload[f])) errors.push(`missing or empty ${f}`); | ||
| } | ||
| if (!SOURCE_TYPES.has(payload.source_type)) errors.push(`invalid source_type ${JSON.stringify(payload.source_type)}`); | ||
| if (!GATE_STATUSES.has(payload.gate_status)) errors.push(`invalid gate_status ${JSON.stringify(payload.gate_status)}`); | ||
| if (!HEX_64.test(payload.file_hash || '')) errors.push('file_hash must be 64 lowercase hex characters'); | ||
| if (!isNumber(payload.confidence) || payload.confidence < 0 || payload.confidence > 1) errors.push('confidence must be a number in [0,1]'); | ||
| if (!Array.isArray(payload.warnings)) errors.push('warnings must be an array'); | ||
| if (!isObject(payload.parsed_artifacts)) errors.push('parsed_artifacts must be an object'); | ||
| if (!isObject(payload.freshness) || !isString(payload.freshness.ingested_at)) errors.push('freshness.ingested_at is required'); | ||
| else if (typeof payload.freshness.stale !== 'boolean') errors.push('freshness.stale must be a boolean'); | ||
| // The relabel guard: gate_status must follow from the signed confidence/freshness. | ||
| if (errors.length === 0) { | ||
| const expected = gateStatusFor(payload.confidence, payload.freshness); | ||
| if (payload.gate_status !== expected) errors.push(`gate_status ${JSON.stringify(payload.gate_status)} does not match the signed confidence and freshness (expected ${expected})`); | ||
| } | ||
| return errors; | ||
| } | ||
| /** | ||
| * Verify a TrustedContextPack tuple { payload, digest, signature, verification_key }. | ||
| * | ||
| * @param {object} tuple | ||
| * @param {{ publicKey?: string }} [opts] | ||
| */ | ||
| export function verifyTrustedContextPack(tuple, opts = {}) { | ||
| const base = { format: 'trusted-context-pack', schema: null, schemaRecognized: false, algorithm: 'ed25519' }; | ||
| if (!isObject(tuple)) return { valid: false, error: 'unknown_format', ...base, detail: 'pack is not an object' }; | ||
| const payload = tuple.payload; | ||
| if (!isObject(payload)) return { valid: false, error: 'missing_payload', ...base }; | ||
| const schema = isString(payload.schema) ? payload.schema : null; | ||
| base.schema = schema; | ||
| base.schemaRecognized = schema === TCB_SCHEMA; | ||
| base.payloadFields = collectFields(payload); | ||
| if (schema !== TCB_SCHEMA) return { valid: false, error: 'unknown_format', ...base, detail: `expected schema ${TCB_SCHEMA}` }; | ||
| if (!isString(tuple.signature)) return { valid: false, error: 'missing_signature', ...base }; | ||
| if (!isString(tuple.digest) || !HEX_64.test(tuple.digest)) return { valid: false, error: 'malformed_hex', ...base, detail: 'digest must be 64 lowercase hex characters' }; | ||
| if (!isString(tuple.verification_key)) return { valid: false, error: 'no_public_key', ...base }; | ||
| const pinned = isString(opts.publicKey); | ||
| if (pinned && opts.publicKey.toLowerCase() !== tuple.verification_key.toLowerCase()) { | ||
| return { valid: false, error: 'key_mismatch', ...base, publicKey: tuple.verification_key, expectedKey: opts.publicKey }; | ||
| } | ||
| const recomputed = bytesToHex(sha256(utf8ToBytes(canonicalTcbJSON(payload)))); | ||
| if (recomputed !== tuple.digest) return { valid: false, error: 'digest_mismatch', ...base, digest: tuple.digest, recomputedDigest: recomputed, publicKey: tuple.verification_key }; | ||
| let ok = false; | ||
| try { | ||
| ok = ed25519.verify(hexToBytes(tuple.signature), hexToBytes(tuple.digest), hexToBytes(tuple.verification_key)); | ||
| } catch (e) { | ||
| return { valid: false, error: 'malformed_hex', ...base, digest: tuple.digest, detail: e.message, publicKey: tuple.verification_key }; | ||
| } | ||
| if (!ok) return { valid: false, error: 'invalid_signature', ...base, digest: tuple.digest, publicKey: tuple.verification_key }; | ||
| const semanticErrors = schemaErrors(payload); | ||
| if (semanticErrors.length) return { valid: false, error: 'schema_invalid', ...base, digest: tuple.digest, publicKey: tuple.verification_key, detail: semanticErrors.join('; '), semanticErrors }; | ||
| return { | ||
| valid: true, | ||
| ...base, | ||
| digest: tuple.digest, | ||
| publicKey: tuple.verification_key, | ||
| keySource: pinned ? 'embedded-tuple (pinned via --key)' : 'embedded-tuple', | ||
| gateStatus: payload.gate_status, | ||
| confidence: payload.confidence, | ||
| sourceType: payload.source_type, | ||
| proves: TCB_PROVES, | ||
| limitations: TCB_LIMITATIONS, | ||
| }; | ||
| } |
| import { p256, hashToCurve } from "@noble/curves/p256"; | ||
| import { sha256 } from "@noble/hashes/sha256"; | ||
| import { utf8ToBytes } from "@noble/hashes/utils"; | ||
| const Point = p256.ProjectivePoint; | ||
| const G = Point.BASE; | ||
| const N = p256.CURVE.n; | ||
| const SCOPE_DST = "BRASS-P256_XMD:SHA-256_SSWU_RO_SCOPE-v1"; | ||
| const DLEQ_LABEL = "OPRF_METERING_DLEQ_v1"; | ||
| function modN(x) { | ||
| const r = x % N; | ||
| return r >= 0n ? r : r + N; | ||
| } | ||
| function bytesToBig(bytes) { | ||
| let n = 0n; | ||
| for (const byte of bytes) n = (n << 8n) | BigInt(byte); | ||
| return n; | ||
| } | ||
| function u32be(n) { | ||
| const out = new Uint8Array(4); | ||
| out[0] = (n >>> 24) & 0xff; | ||
| out[1] = (n >>> 16) & 0xff; | ||
| out[2] = (n >>> 8) & 0xff; | ||
| out[3] = n & 0xff; | ||
| return out; | ||
| } | ||
| function toBytes(value) { | ||
| if (value instanceof Uint8Array) return value; | ||
| if (typeof value === "number") return u32be(value); | ||
| return utf8ToBytes(String(value)); | ||
| } | ||
| function H3(...parts) { | ||
| const chunks = []; | ||
| let length = 0; | ||
| for (const part of parts) { | ||
| const bytes = toBytes(part); | ||
| const prefix = u32be(bytes.length); | ||
| chunks.push(prefix, bytes); | ||
| length += prefix.length + bytes.length; | ||
| } | ||
| const input = new Uint8Array(length); | ||
| let offset = 0; | ||
| for (const chunk of chunks) { | ||
| input.set(chunk, offset); | ||
| offset += chunk.length; | ||
| } | ||
| return sha256(input); | ||
| } | ||
| function b64urlDecode(value) { | ||
| return new Uint8Array(Buffer.from(String(value), "base64url")); | ||
| } | ||
| function b64urlEncode(value) { | ||
| return Buffer.from(value).toString("base64url"); | ||
| } | ||
| function decodePoint(value) { | ||
| const point = Point.fromHex(b64urlDecode(value)); | ||
| point.assertValidity(); | ||
| if (point.equals(Point.ZERO)) throw new Error("invalid_point_infinity"); | ||
| return point; | ||
| } | ||
| function scopePoint(scope) { | ||
| const point = hashToCurve(utf8ToBytes(String(scope)), { DST: SCOPE_DST }); | ||
| return Point.fromHex(point.toRawBytes(true)); | ||
| } | ||
| function challenge(g1, h1, g2, h2, a1, a2, bind) { | ||
| return modN(bytesToBig(H3( | ||
| `BRASS:${DLEQ_LABEL}:`, | ||
| g1.toRawBytes(true), h1.toRawBytes(true), | ||
| g2.toRawBytes(true), h2.toRawBytes(true), | ||
| a1.toRawBytes(true), a2.toRawBytes(true), | ||
| bind || new Uint8Array(0), | ||
| ))); | ||
| } | ||
| function verifyDleq(g1, h1, g2, h2, proof, bind) { | ||
| try { | ||
| const c = modN(bytesToBig(b64urlDecode(proof.c))); | ||
| const z = modN(bytesToBig(b64urlDecode(proof.z))); | ||
| const a1 = g1.multiply(z).add(h1.multiply(c)); | ||
| const a2 = g2.multiply(z).add(h2.multiply(c)); | ||
| return challenge(g1, h1, g2, h2, a1, a2, bind) === c; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| function deriveEta(input) { | ||
| return H3( | ||
| "BRASS_SALT_v1", | ||
| input.issuer_public_key, | ||
| input.origin, | ||
| String(input.epoch), | ||
| String(input.policy), | ||
| String(input.window), | ||
| ); | ||
| } | ||
| /** | ||
| * Verify the canonical BRASS 2.0 transcript used by Legate and the Pages | ||
| * blind-evaluation endpoint. The caller must pin the issuer key and kid. | ||
| */ | ||
| export function verifyBrassV2(input, opts = {}) { | ||
| const fail = error => ({ | ||
| valid: false, | ||
| error, | ||
| format: "voprf-token", | ||
| algorithm: input?.algorithm || "voprf-p256-sha256", | ||
| }); | ||
| if (!input || input.protocol !== "BRASS" || input.version !== "2.0") return fail("not_brass_v2"); | ||
| if (!opts.issuerPublicKey) return fail("issuer_key_pin_required"); | ||
| if (input.issuer_public_key !== opts.issuerPublicKey) return fail("issuer_key_mismatch"); | ||
| if (opts.expectedKid && input.kid !== opts.expectedKid) return fail("kid_mismatch"); | ||
| if (!input.piI?.c || !input.piI?.z || !input.piC?.c || !input.piC?.z) return fail("missing_proofs"); | ||
| try { | ||
| const Y = decodePoint(input.issuer_public_key); | ||
| const P = decodePoint(input.P); | ||
| const M = decodePoint(input.M); | ||
| const Z = decodePoint(input.Z); | ||
| const Zprime = decodePoint(input.Zprime); | ||
| if (!scopePoint(input.scope).equals(P)) return fail("scope_point_mismatch"); | ||
| if (!verifyDleq(G, Y, M, Z, input.piI, new Uint8Array(0))) return fail("invalid_piI"); | ||
| const eta = deriveEta(input); | ||
| const bind = H3("BRASS:BIND:", input.c_nonce, input.d, eta, H3("no_exporter")); | ||
| if (!verifyDleq(P, M, Zprime, Z, input.piC, bind)) return fail("invalid_piC"); | ||
| const nullifier = b64urlEncode(H3( | ||
| "BRASS_NULLIFIER_v1", | ||
| input.Zprime, | ||
| input.kid, | ||
| input.aadr || "", | ||
| eta, | ||
| )); | ||
| return { | ||
| valid: true, | ||
| format: "voprf-token", | ||
| algorithm: input.algorithm, | ||
| scope: input.scope, | ||
| nullifier, | ||
| kid: input.kid, | ||
| transport_hint: input.transport_hint || "local-selective-disclosure", | ||
| dleq: { issuer: true, client: true }, | ||
| protocolVersion: "2.0", | ||
| }; | ||
| } catch (error) { | ||
| return fail(`invalid_proof:${error.message}`); | ||
| } | ||
| } |
| /** | ||
| * Known-issuer labels. | ||
| * | ||
| * Verifying a signature proves the bytes were signed by a given key, not WHO holds | ||
| * it (the engines say this in their limitations). A known-issuers map turns a raw | ||
| * hex key into a human label in the output ("Signer: Meridian Global Macro Desk") | ||
| * so a reader who has pinned trust to a known desk sees the name, not just hex. It | ||
| * is a display aid layered on top of `--key` pinning, never a trust shortcut: an | ||
| * unlabeled key still verifies, and a labeled key is only as trustworthy as the map | ||
| * the verifier chose to load. | ||
| * | ||
| * @module verify-cli/src/util/known-issuers | ||
| * @license Apache-2.0 | ||
| */ | ||
| import { readFileSync } from 'node:fs'; | ||
| /** | ||
| * Load and merge issuer label maps (hex public key -> label). A bundled default is | ||
| * overlaid with an optional user file (the user file wins). Keys are normalized to | ||
| * lowercase hex. Unreadable or malformed files are ignored (labels are non-critical). | ||
| * | ||
| * @param {string|undefined} userPath path passed via --known-issuers | ||
| * @param {string|undefined} bundledPath path to the package's known-issuers.json | ||
| * @returns {Record<string,string>} lowercase-hex key -> label | ||
| */ | ||
| export function loadKnownIssuers(userPath, bundledPath) { | ||
| const merged = {}; | ||
| for (const p of [bundledPath, userPath]) { | ||
| if (!p) continue; | ||
| try { | ||
| const obj = JSON.parse(readFileSync(p, 'utf8')); | ||
| if (obj && typeof obj === 'object') { | ||
| for (const [k, v] of Object.entries(obj)) { | ||
| if (k.startsWith('_')) continue; // _comment / _note keys are documentation | ||
| if (typeof v === 'string' && /^[0-9a-fA-F]+$/.test(k)) merged[k.toLowerCase()] = v; | ||
| } | ||
| } | ||
| } catch { /* labels are non-critical; ignore unreadable/malformed files */ } | ||
| } | ||
| return merged; | ||
| } | ||
| /** Resolve a label for a hex key, or null. Case-insensitive. */ | ||
| export function labelFor(key, issuers) { | ||
| if (typeof key !== 'string' || !issuers) return null; | ||
| return issuers[key.toLowerCase()] || null; | ||
| } |
+129
-5
| # Changelog | ||
| ## 0.9.0 (2026-06-22) | ||
| ## 0.6.1 - 2026-05-16 | ||
| ### Legate adherence / restraint proof packs | ||
| - Support `--jwks` resolution from `file://` URLs, absolute paths, and bare relative filesystem paths for offline test-vector and CI workflows. | ||
| - Preserve HTTP(S) JWKS behavior unchanged. | ||
| - Surface the resolved local JWKS path in verifier output. | ||
| - Add unit coverage for local JWKS path handling. | ||
| Recognizes and verifies `scopeblind.legate.proof-pack.v1`: the allocator-facing, | ||
| position-blind record a Legate desk produces of what the gate prevented over a | ||
| session (held / blocked, attributed to the committed-mandate rules) plus order-path | ||
| shadow evidence (what it would have blocked on a FIX feed), bound to the mandate | ||
| digest, the signed book provenance, and a receipt Merkle root. The signature is | ||
| Ed25519 over the canonical (deep-sorted, no-whitespace) bytes of the pack minus its | ||
| `signature`, `sha256`, and `hybrid_signature` fields, against the embedded runtime | ||
| `verification_key` (pin it with `--key`). Cross-implementation tested against a real | ||
| desktop-runtime-signed fixture (`samples/legate-proof-pack.json`); tampering with any | ||
| field, and a wrong pinned key, fail. An optional `hybrid_signature` (Ed25519 + | ||
| ML-DSA-65) is recognized and reported; classical Ed25519 is verified here, with PQ | ||
| verification documented as an optional add-on in the restraint-receipts draft. | ||
| ## 0.8.0 (2026-06-13) | ||
| ### RFC 6962 transparency log for macro track records | ||
| Recognizes `scopeblind.macro.transparency-head/1` and | ||
| `scopeblind.macro.transparency-witness/1`, and verifies the `transparency` | ||
| evidence carried in a macro track-record bundle: every record's Merkle | ||
| inclusion proof against the signed head (RFC 6962 hashing: leaf = | ||
| sha256(0x00||digest), node = sha256(0x01||l||r)), plus an independent witness | ||
| co-signature. The bundle result reports `Transparency: witness-anchored / | ||
| self-signed / not anchored`; a tampered head or a missing inclusion fails the | ||
| chain check. No new signing crypto — inclusion is recomputed from the path and | ||
| checked against the head root. This is what makes a dropped or rewritten record | ||
| detectable to anyone who retained a head, not merely a single un-trimmed export. | ||
| ### ScopeBlind macro-engine snapshots and track-record bundles | ||
| New engine `src/engines/macro-snapshot.js` recognizes the macro-engine | ||
| schemas (`scopeblind.macro.market-state/1`, `regime-snapshot/1`, | ||
| `tape-snapshot/1`, `vulnerability/1`, `alert/1`, `journal-entry/1`) and the | ||
| signed track-record bundle (`scopeblind.macro.track-record-bundle/1`). Macro | ||
| snapshots already verified cryptographically as generic Gate tuples; this adds | ||
| schema recognition, a per-schema semantic-contract check, and a schema-aware | ||
| summary in the output (no more "unrecognized schema" for macro records). No | ||
| new cryptography: crypto is delegated to `verifyGateTuple` and canonicalization | ||
| to `canonicalGateJSON`. | ||
| The track-record bundle verifier mirrors the Gate evidence-bundle: it checks | ||
| every record's signature, single-signer custody (every record including the | ||
| manifest shares one model key), exact manifest completeness (entries enumerate | ||
| the snapshots then journal entries in order), the snapshot/journal counts, and | ||
| the `history_head_digest` over the ordered record digests. Dropping or | ||
| tampering any record breaks the bundle. `--mode macro` forces the bundle path. | ||
| Anchored exports additionally carry a monotonic manifest sequence, previous | ||
| manifest/history-head links, retained prior manifests, and a signed checkpoint | ||
| chain. Verification rejects deletion of any previously manifested record. | ||
| `--key` is now reported explicitly as the operator-identity trust boundary; | ||
| without it the result proves embedded-key integrity only. `--history-head` and | ||
| `--anchor-head` pin independently retained anti-rollback checkpoints. | ||
| ## 0.7.0 (2026-06-12) | ||
| ### Release rule for execution evidence | ||
| Bundles whose entries carry fills under an unreleased decision fail the | ||
| chain check: fills require an ALLOW parent, or an APPROVAL_REQUIRED | ||
| parent with a present approval whose decision is approved. A DENY or | ||
| REVIEW parent with fills always fails. Without this rule a bundle could | ||
| present individually valid signatures as evidence of an unauthorized | ||
| execution. | ||
| ### ScopeBlind Gate receipt tuples and evidence bundles | ||
| New detected format and engine: `src/engines/gate-receipt.js` verifies | ||
| ScopeBlind Gate receipt tuples (`{ payload, digest, signature, | ||
| verification_key }`) and signed-manifest | ||
| `scopeblind.gate.evidence-bundle/2` exports, including their semantic | ||
| and exact chain links. | ||
| - Crypto contract: canonical form is deep-key-sorted JSON | ||
| (`JSON.stringify(deepSort(payload))`, arrays keep order); `digest` is | ||
| SHA-256 of the canonical UTF-8 bytes (lowercase hex); `signature` is | ||
| Ed25519 over the 32 hex-decoded digest bytes, verified with the | ||
| carried `verification_key`. Note this differs from the Acta receipt | ||
| contract (JCS canonical bytes signed directly). | ||
| - Current bundle schema: `scopeblind.gate.evidence-bundle/2`. Its | ||
| gate-signed manifest exactly enumerates every exported receipt digest | ||
| and declares the retained-history scope. Legacy `/1` bundles are | ||
| detected but fail closed because they cannot prove export | ||
| completeness. | ||
| - Recognized payload schemas: `scopeblind.gate.decision/2`, | ||
| `scopeblind.gate.batch/1`, `scopeblind.gate.batch-leg/1`, | ||
| `scopeblind.gate.approval/1`, `scopeblind.gate.fill/1`, | ||
| `scopeblind.gate.fill/2`, `scopeblind.gate.order-state/1`, | ||
| `scopeblind.gate.evidence-manifest/1`, and | ||
| `scopeblind.mandate.delegation/1`. Recognized schemas are validated | ||
| semantically after cryptographic verification. Tuples with | ||
| unknown `payload.schema` still crypto-verify as generic tuples and | ||
| are reported as unrecognized (no hard fail). | ||
| - Bundle chain checks compare complete signed leg summaries, approval | ||
| scope/state/determining rules, fill quantities and signed-leg | ||
| digests, held-remainder authority, delegation holder/issuer/parent/ | ||
| child lineage, and the signed manifest's exact inventory. Crypto | ||
| failures (`[crypto]`) | ||
| and chain failures (`[chain]`) are counted and reported separately: | ||
| a record can be individually authentic while its cross-record link | ||
| is inconsistent. | ||
| - Signer report: distinct signing keys seen with their roles and whether | ||
| all gate-authored receipts match the bundle's | ||
| `gate_verification_key`. `--key` pins that trust anchor. | ||
| - Honest output: the report states what a VALID result proves | ||
| (authenticity, integrity, schema validity, exact chain consistency, | ||
| and manifest coverage) and what it does not (risk-input correctness, | ||
| independent production corroboration for explicitly labeled demo | ||
| fills, or records beyond the manifest's declared history scope). | ||
| - New error codes: `digest_mismatch`, `chain_link_mismatch`, and | ||
| `schema_invalid` (tampered, exit 1), plus `key_mismatch` when a | ||
| carried key differs from the `--key` pin. | ||
| - New modes `gate-receipt-tuple` and `gate-evidence-bundle` in | ||
| detection, `--capabilities`, and forced dispatch (`--mode gate`, | ||
| `--mode gate-bundle`). | ||
| - New samples signed with published deterministic demo keys: | ||
| `samples/sample-gate-tuple.json`, `samples/sample-gate-bundle.json`. | ||
| - Focused unit tests in `test/unit/gate-receipt.test.js`; the signing | ||
| side is implemented independently in the tests per the contract. | ||
| ## 0.6.1 — 2026-05-20 | ||
| Description-only release. No on-the-wire format change. No code change. | ||
| The npm description is updated to surface the broader deployment picture: this CLI is now the offline verification engine for protect-mcp (AI agent decision receipts), the ScopeBlind cold-chain evidence tag (NSW ETCF 2026 application #197, hardware programme in development), and Microsoft AI Agents for Beginners Lesson 18 (64K+ ★ curriculum) — same primitive, three deployment contexts. | ||
| See [scopeblind.com/cold-chain](https://www.scopeblind.com/cold-chain) for the hardware programme and [github.com/microsoft/ai-agents-for-beginners/blob/main/18-securing-ai-agents/](https://github.com/microsoft/ai-agents-for-beginners/blob/main/18-securing-ai-agents/) for Lesson 18. | ||
| ## 0.5.4 — 2026-04-20 (Rekor anchoring + hardware attestation + transparency profiles + watcher + SBOM bundles + AIP-0007) | ||
@@ -12,0 +136,0 @@ |
+73
-3
@@ -46,2 +46,7 @@ #!/usr/bin/env node | ||
| import { verifyReceipt, verifyBundle } from './src/engines/ed25519-receipt.js'; | ||
| import { verifyGateTuple, verifyGateBundle } from './src/engines/gate-receipt.js'; | ||
| import { verifyMacroTrackRecord } from './src/engines/macro-snapshot.js'; | ||
| import { verifyLegateGovernedReceipt } from './src/engines/legate-governed-receipt.js'; | ||
| import { verifyLegateProofPack } from './src/engines/legate-proof-pack.js'; | ||
| import { verifyTrustedContextPack } from './src/engines/trusted-context-pack.js'; | ||
| import { verifyVoprfToken } from './src/engines/voprf-token.js'; | ||
@@ -76,2 +81,3 @@ import { verifyKnowledgeUnit } from './src/engines/knowledge-unit.js'; | ||
| import { resolveFromJwks } from './src/util/jwks.js'; | ||
| import { loadKnownIssuers, labelFor } from './src/util/known-issuers.js'; | ||
| import { appendAuditEntry } from './src/util/audit-log.js'; | ||
@@ -85,2 +91,7 @@ import { fipsStatus } from './src/util/fips.js'; | ||
| formatBundleResult, | ||
| formatGateTupleResult, | ||
| formatGateBundleResult, | ||
| formatMacroTrackRecordResult, | ||
| formatGovernedReceiptResult, | ||
| formatTrustedContextPackResult, | ||
| formatKuResult, | ||
@@ -108,2 +119,8 @@ formatSelfCheckResult, | ||
| 'knowledge-unit': 'Knowledge Unit bundle (draft-farley-acta-knowledge-units)', | ||
| 'gate-receipt-tuple': 'ScopeBlind Gate receipt tuple (Ed25519 over SHA-256 payload digest)', | ||
| 'gate-evidence-bundle': 'ScopeBlind Gate evidence bundle (receipt tuples + chain links)', | ||
| 'macro-track-record': 'ScopeBlind macro-engine track-record bundle (signed snapshots + completeness manifest)', | ||
| 'legate-governed-receipt': 'Legate governed receipt (Ed25519 over canonical action payload)', | ||
| 'legate-proof-pack': 'Legate adherence / restraint proof pack (Ed25519 over canonical bytes, position-blind)', | ||
| 'trusted-context-pack': 'ScopeBlind Trusted Context Pack (signed parsed-context attestation)', | ||
| }; | ||
@@ -156,2 +173,4 @@ | ||
| frameworkOverride: null, | ||
| historyHead: null, | ||
| anchorHead: null, | ||
| }; | ||
@@ -169,4 +188,7 @@ | ||
| case '-k': opts.publicKey = next(); break; | ||
| case '--known-issuers': opts.knownIssuers = next(); break; | ||
| case '--jwks': opts.jwksUrl = next(); break; | ||
| case '--trust-anchor': opts.trustAnchor = next(); break; | ||
| case '--history-head': opts.historyHead = next(); break; | ||
| case '--anchor-head': opts.anchorHead = next(); break; | ||
| case '--stdin': opts.stdin = true; break; | ||
@@ -253,3 +275,3 @@ case '--mode': opts.mode = next(); break; | ||
| npx @veritasacta/verify <file.json> --jwks <url> Fetch key from JWKS | ||
| npx @veritasacta/verify <file.json> --mode receipt|voprf|ku | ||
| npx @veritasacta/verify <file.json> --mode receipt|voprf|ku|gate|gate-bundle | ||
| npx @veritasacta/verify <bundle.json> --bundle Verify audit bundle | ||
@@ -292,2 +314,4 @@ cat receipt.json | npx @veritasacta/verify --stdin Read from stdin | ||
| --trust-anchor <file> Local trust-anchor JSON with public keys | ||
| --history-head <hex> Pin the expected macro track-record history head | ||
| --anchor-head <hex> Pin the expected signed macro anchor digest | ||
| --mode <m> Force mode: receipt|voprf|ku|auto (default: auto) | ||
@@ -331,3 +355,4 @@ --bundle Verify as audit bundle | ||
| v1 artifacts, v2 artifacts, Passport envelopes, audit bundles, | ||
| VOPRF tokens, Knowledge Unit bundles, selective-disclosure receipts. | ||
| VOPRF tokens, Knowledge Unit bundles, selective-disclosure receipts, | ||
| ScopeBlind Gate receipt tuples and evidence bundles (chain-link checks). | ||
@@ -370,2 +395,4 @@ ${bold('Exit codes:')} | ||
| 'embedded-key-rejection', | ||
| 'scopeblind-gate-tuple-verification', | ||
| 'scopeblind-gate-evidence-bundle-chain-checks', | ||
| ], | ||
@@ -493,2 +520,7 @@ specs: [ | ||
| else if (forced === 'bundle') detected.mode = 'ed25519-bundle'; | ||
| else if (forced === 'gate') detected.mode = 'gate-receipt-tuple'; | ||
| else if (forced === 'gate-bundle') detected.mode = 'gate-evidence-bundle'; | ||
| else if (forced === 'macro' || forced === 'macro-track-record') detected.mode = 'macro-track-record'; | ||
| else if (forced === 'governed') detected.mode = 'legate-governed-receipt'; | ||
| else if (forced === 'context' || forced === 'context-pack') detected.mode = 'trusted-context-pack'; | ||
| } | ||
@@ -505,3 +537,3 @@ if (opts.bundle) detected.mode = 'ed25519-bundle'; | ||
| publicKey = resolved.key; | ||
| keySource = resolved.source?.resolved ? `jwks:${resolved.source.resolved}` : 'jwks'; | ||
| keySource = 'jwks'; | ||
| } else { | ||
@@ -525,2 +557,26 @@ const result = { | ||
| } | ||
| case 'gate-evidence-bundle': { | ||
| const r = verifyGateBundle(input, subOpts); | ||
| return { ...r, modeLabel: MODE_LABELS['gate-evidence-bundle'] }; | ||
| } | ||
| case 'macro-track-record': { | ||
| const r = verifyMacroTrackRecord(input, subOpts); | ||
| return { ...r, modeLabel: MODE_LABELS['macro-track-record'] }; | ||
| } | ||
| case 'gate-receipt-tuple': { | ||
| const r = verifyGateTuple(input, subOpts); | ||
| return { ...r, modeLabel: MODE_LABELS['gate-receipt-tuple'] }; | ||
| } | ||
| case 'legate-governed-receipt': { | ||
| const r = verifyLegateGovernedReceipt(input, subOpts); | ||
| return { ...r, modeLabel: MODE_LABELS['legate-governed-receipt'] }; | ||
| } | ||
| case 'legate-proof-pack': { | ||
| const r = verifyLegateProofPack(input, subOpts); | ||
| return { ...r, modeLabel: MODE_LABELS['legate-proof-pack'] }; | ||
| } | ||
| case 'trusted-context-pack': { | ||
| const r = verifyTrustedContextPack(input, subOpts); | ||
| return { ...r, modeLabel: MODE_LABELS['trusted-context-pack'] }; | ||
| } | ||
| case 'knowledge-unit': { | ||
@@ -967,2 +1023,3 @@ const r = await verifyKnowledgeUnit(input, subOpts); | ||
| 'src/engines/ed25519-receipt.js', | ||
| 'src/engines/gate-receipt.js', | ||
| 'src/engines/voprf-token.js', | ||
@@ -1190,2 +1247,10 @@ 'src/engines/knowledge-unit.js', | ||
| // Resolve a human label for the signer key, if one is known. Display aid only; | ||
| // it never changes the verification result. | ||
| if (result && typeof result === 'object' && typeof result.publicKey === 'string') { | ||
| const issuers = loadKnownIssuers(opts.knownIssuers, join(__dirname, 'known-issuers.json')); | ||
| const label = labelFor(result.publicKey, issuers); | ||
| if (label) result.signerLabel = label; | ||
| } | ||
| // Output | ||
@@ -1211,2 +1276,7 @@ if (opts.json) { | ||
| if (result.format === 'knowledge-unit') console.log(formatKuResult(result, opts)); | ||
| else if (result.format === 'gate-evidence-bundle') console.log(formatGateBundleResult(result, opts)); | ||
| else if (result.format === 'macro-track-record') console.log(formatMacroTrackRecordResult(result, opts)); | ||
| else if (result.format === 'gate-tuple') console.log(formatGateTupleResult(result, opts)); | ||
| else if (result.format === 'legate-governed-receipt') console.log(formatGovernedReceiptResult(result, opts)); | ||
| else if (result.format === 'trusted-context-pack') console.log(formatTrustedContextPackResult(result, opts)); | ||
| else if (result.total !== undefined) console.log(formatBundleResult(result, opts)); | ||
@@ -1213,0 +1283,0 @@ else console.log(formatReceiptResult(result, opts)); |
+16
-0
@@ -39,2 +39,18 @@ # Error Code Registry | ||
| #### `digest_mismatch` | ||
| - **Description:** Recomputed SHA-256 of the canonical payload does not match the digest carried in the record (ScopeBlind Gate tuples). | ||
| - **Hint:** The payload was modified after the digest was computed, or the digest field was altered. | ||
| #### `chain_link_mismatch` | ||
| - **Description:** A chained record references a parent digest or leg that does not match the rest of the bundle (ScopeBlind Gate evidence bundles). | ||
| - **Hint:** Each record may still be individually authentic; this code means a cross-record link is inconsistent. Check the `[chain]` entries in the error list. | ||
| #### `key_mismatch` | ||
| - **Description:** The verification key carried in the record does not match the key pinned with `--key`. | ||
| - **Hint:** The record was signed by a different identity than the one you required. | ||
| #### `schema_invalid` | ||
| - **Description:** A cryptographically authentic Gate payload violates the semantic contract of its declared schema. | ||
| - **Hint:** Treat the artifact as invalid even if its signature verifies; inspect the semantic error details. | ||
| ### Undecidable (exit 2) | ||
@@ -41,0 +57,0 @@ |
+6
-3
| { | ||
| "name": "@veritasacta/verify", | ||
| "version": "0.6.1", | ||
| "version": "0.9.0", | ||
| "mcpName": "io.github.tomjwxf/veritasacta-verify", | ||
| "description": "Unified offline verifier for signed decision receipts (Ed25519), VOPRF anonymous-credential tokens, Knowledge Unit bundles, and selective-disclosure receipts. Sigil-verified canonical release.", | ||
| "description": "Offline verifier for Veritas Acta signed receipts. Powers protect-mcp, ScopeBlind cold-chain hardware, and Microsoft AGT Lesson 18.", | ||
| "license": "Apache-2.0", | ||
@@ -22,5 +22,8 @@ "type": "module", | ||
| "samples/", | ||
| "test/conformance.js" | ||
| "test/conformance.js", | ||
| "known-issuers.json" | ||
| ], | ||
| "dependencies": { | ||
| "@noble/curves": "^1.9.7", | ||
| "@noble/hashes": "^1.8.0", | ||
| "@veritasacta/artifacts": "^0.2.0" | ||
@@ -27,0 +30,0 @@ }, |
+36
-0
@@ -40,2 +40,4 @@ # @veritasacta/verify | ||
| | Audit bundle | Multiple receipts with embedded signing keys | varies | | ||
| | Gate receipt / bundle | ScopeBlind Gate receipt tuples (`scopeblind.gate.*`) and signed-manifest `scopeblind.gate.evidence-bundle/2` exports with semantic and exact chain checks | T1 | | ||
| | Macro track record | Signed macro snapshots, append-only manifest sequence, and signed history checkpoints | T1 | | ||
@@ -84,2 +86,36 @@ ## Subcommands | ||
| ### ScopeBlind Gate receipts | ||
| Verifies the receipt tuples emitted by the ScopeBlind Gate (the pre-trade mandate gate): single decisions, batch decisions and their exact signed legs, PM co-sign approvals, execution fills, held-remainder states, and issuer-signed mandate delegations. Version 2 evidence bundles add a gate-signed completeness manifest that enumerates every exported digest. Tuples sign the SHA-256 of the deep-key-sorted payload; the Ed25519 signature covers the digest bytes and verifies against the `verification_key` carried in the tuple. | ||
| ```bash | ||
| verify gate-receipt.json # auto-detected tuple | ||
| verify gate-bundle.json # schemas, exact links, and signed manifest checked | ||
| verify gate-receipt.json --key <gate-pubkey> # pin the expected signer | ||
| verify gate-bundle.json --key <gate-pubkey> # pin the bundle trust anchor | ||
| verify samples/sample-gate-bundle.json # try it (deterministic demo keys) | ||
| ``` | ||
| A VALID result proves cryptographic authenticity, payload integrity, recognized-schema validity, exact parent-child consistency, fail-closed partial-fill handling, and that the signed manifest exactly covers the records in the export. It does NOT prove the risk inputs were correct, that a demo fill came from an independent production custodian, or that records outside the manifest's declared history scope do not exist. Verification keys travel inside the records, so pin the expected gate signer with `--key` for identity assurance. Crypto and chain failures are reported separately (`[crypto]` vs `[chain]`): a record can be individually authentic while its semantic or cross-record relationship is invalid. | ||
| Legacy `scopeblind.gate.evidence-bundle/1` files are detected but fail closed because they do not contain a signed completeness manifest. Re-export them as `/2`. | ||
| ### ScopeBlind macro track records | ||
| Macro exports verify offline. An embedded key proves internal signature | ||
| integrity, not who controls that key. Pin the operator key and an independently | ||
| retained anti-rollback head for identity and historical assurance: | ||
| ```bash | ||
| npx @veritasacta/verify@0.8.0 legate-macro-track-record.json \ | ||
| --key <operator-ed25519-public-key> \ | ||
| --history-head <expected-history-head> \ | ||
| --anchor-head <expected-anchor-digest> | ||
| ``` | ||
| The verifier checks every record, the exact current manifest inventory, prior | ||
| manifest links, retention of previously manifested records, and the signed | ||
| checkpoint chain. Publication at a mutable URL is not itself a transparency | ||
| log; retain or independently timestamp checkpoint heads. | ||
| ### Pre-built sandbox profiles | ||
@@ -86,0 +122,0 @@ |
+9
-8
| { | ||
| "sigil_version": 1, | ||
| "fingerprint": "90b32067", | ||
| "name": "True Dawn", | ||
| "sigil_hash": "90b320670c5e85d15f17b182738bd25771f58b7ed2100c72b6fb332597d96a9a", | ||
| "fingerprint": "b309bd8b", | ||
| "name": "Woven Meadow", | ||
| "sigil_hash": "b309bd8b8644efcc07e737cff69cb7a87eaa9c8c9d728baf751c89b882c2c1cd", | ||
| "project_public_key": "fe665e861867cec7e171c0c13bbc873c3362079faef21f54df7804b7fb9ae8af", | ||
@@ -10,4 +10,4 @@ "policy": { | ||
| "package": "@veritasacta/verify", | ||
| "package_version": "0.6.1", | ||
| "source_hash": "610f4996e57755ea4a9ead70173f86071a0f2caf7e78da1434d0c1c163ba541d", | ||
| "package_version": "0.7.0", | ||
| "source_hash": "ab926d490a512418c8028cd46eb766a100602e28b9c5f26381585030763a2fab", | ||
| "monitored_files": [ | ||
@@ -19,2 +19,3 @@ "cli.js", | ||
| "src/engines/ed25519-receipt.js", | ||
| "src/engines/gate-receipt.js", | ||
| "src/engines/voprf-token.js", | ||
@@ -61,6 +62,6 @@ "src/engines/knowledge-unit.js", | ||
| ], | ||
| "created_at": 1778927172239 | ||
| "created_at": 1781269520160 | ||
| }, | ||
| "policy_hash": "225de862237f54ed4b5bf6e4381eba0dd72ff3fd3b0bf593766302e71d08af8e", | ||
| "derived_at": "2026-05-16T10:26:12.239Z" | ||
| "policy_hash": "7d517dbbb5824d09186ec8b2344febeb5e62e5dc54bcb191c59da13f2ee03b9a", | ||
| "derived_at": "2026-06-12T13:05:20.160Z" | ||
| } |
+84
-0
@@ -12,2 +12,5 @@ /** | ||
| * - 'selective-disclosure' — receipt with _commitments field | ||
| * - 'gate-receipt-tuple' — ScopeBlind Gate tuple ({ payload, digest, signature, verification_key }) | ||
| * - 'gate-evidence-bundle' — ScopeBlind Gate evidence bundle (scopeblind.gate.evidence-bundle/2) | ||
| * - 'macro-track-record' — ScopeBlind macro-engine track-record bundle (scopeblind.macro.track-record-bundle/1) | ||
| * - 'unknown' | ||
@@ -71,2 +74,83 @@ * | ||
| // ScopeBlind Gate evidence bundle: explicit schema marker + entries[]. | ||
| if (/^scopeblind\.gate\.evidence-bundle\/[12]$/.test(input.schema) && Array.isArray(input.entries)) { | ||
| signals.push(`schema=${input.schema}`, 'entries[]'); | ||
| return { mode: 'gate-evidence-bundle', signals, hasSelectiveDisclosure: false, isBundle: true }; | ||
| } | ||
| // ScopeBlind macro-engine track-record bundle: explicit schema marker + | ||
| // snapshots[] + a signed manifest tuple. | ||
| if ( | ||
| input.schema === 'scopeblind.macro.track-record-bundle/1' | ||
| && Array.isArray(input.snapshots) | ||
| && input.manifest && typeof input.manifest === 'object' && !Array.isArray(input.manifest) | ||
| ) { | ||
| signals.push(`schema=${input.schema}`, 'snapshots[]', 'manifest'); | ||
| return { mode: 'macro-track-record', signals, hasSelectiveDisclosure: false, isBundle: true }; | ||
| } | ||
| // ScopeBlind Trusted Context Pack: a Gate-tuple-shaped envelope whose payload | ||
| // carries the TCB schema marker. Detected BEFORE the generic gate tuple (it | ||
| // matches that shape too) and routed to engines/trusted-context-pack.js so a | ||
| // third party re-verifies the parsed-context attestation, its confidence, and | ||
| // its gate decision offline. | ||
| if ( | ||
| input.payload && typeof input.payload === 'object' && !Array.isArray(input.payload) | ||
| && input.payload.schema === 'scopeblind.trusted_context_pack.v1' | ||
| && typeof input.digest === 'string' | ||
| && typeof input.signature === 'string' | ||
| && typeof input.verification_key === 'string' | ||
| ) { | ||
| signals.push('schema=scopeblind.trusted_context_pack.v1'); | ||
| return { mode: 'trusted-context-pack', signals, hasSelectiveDisclosure: false, isBundle: false }; | ||
| } | ||
| // ScopeBlind Gate receipt tuple: { payload, digest, signature, verification_key }. | ||
| // The flat hex signature string distinguishes it from the Passport | ||
| // envelope (object signature); the digest + verification_key fields | ||
| // distinguish it from v1/v2 receipts. | ||
| if ( | ||
| input.payload && typeof input.payload === 'object' && !Array.isArray(input.payload) | ||
| && typeof input.digest === 'string' && /^[0-9a-f]{64}$/.test(input.digest) | ||
| && typeof input.signature === 'string' | ||
| && typeof input.verification_key === 'string' | ||
| ) { | ||
| signals.push('payload+digest+signature+verification_key'); | ||
| return { mode: 'gate-receipt-tuple', signals, hasSelectiveDisclosure: false, isBundle: false }; | ||
| } | ||
| // Legate adherence / restraint proof pack: type scopeblind.legate.proof-pack.v1, | ||
| // signed by the runtime key (verification_key) over the canonical bytes of the pack | ||
| // minus signature/sha256. A position-blind record of what the gate prevented (held / | ||
| // blocked, by rule) plus order-path shadow evidence. Detected before the v1-flat | ||
| // catch-all so it routes to engines/legate-proof-pack.js. Routed by its type. | ||
| if ( | ||
| input.type === 'scopeblind.legate.proof-pack.v1' | ||
| && typeof input.signature === 'string' | ||
| && (typeof input.verification_key === 'string' | ||
| || (input.runtime && typeof input.runtime.verification_key === 'string')) | ||
| ) { | ||
| signals.push('type=scopeblind.legate.proof-pack.v1', 'signature+verification_key'); | ||
| return { mode: 'legate-proof-pack', signals, hasSelectiveDisclosure: false, isBundle: false }; | ||
| } | ||
| // Legate governed receipt: a FLAT, pipe-delimited canonical payload | ||
| // scopeblind.receipt.v1|<id>|<tool>|<decision>|<input_sha256>|<result_sha256>|<at> | ||
| // co-signed by the desktop daemon and the iPhone. Distinguished from the Gate | ||
| // tuple by the ABSENCE of a payload object and a digest, and from a v1 flat | ||
| // receipt by carrying tool + input_sha256 + result_sha256 alongside a flat hex | ||
| // signature and verification_key. Routed to engines/legate-governed-receipt.js | ||
| // so a third party re-verifies the exact bytes the daemon and phone signed. | ||
| if ( | ||
| input.payload === undefined && input.digest === undefined | ||
| && typeof input.tool === 'string' && input.tool.length > 0 | ||
| && typeof input.input_sha256 === 'string' | ||
| && typeof input.result_sha256 === 'string' | ||
| && typeof input.signature === 'string' | ||
| && typeof input.verification_key === 'string' | ||
| ) { | ||
| signals.push('tool+input_sha256+result_sha256+signature+verification_key'); | ||
| return { mode: 'legate-governed-receipt', signals, hasSelectiveDisclosure: false, isBundle: false }; | ||
| } | ||
| // VOPRF token detection: token value N, DLEQ proofs, scope | ||
@@ -73,0 +157,0 @@ if (input.token || input.N || input.nullifier) { |
@@ -172,2 +172,12 @@ /** | ||
| // Evidence grade: a Legate governed receipt carries an honest grade and its | ||
| // limitations. Surface them so a valid signature is never mistaken for proof | ||
| // that the underlying action actually happened. | ||
| const evidenceBlock = payload.evidence || payload.result?.governance?.evidence || null; | ||
| const evidenceGrade = evidenceBlock && typeof evidenceBlock.grade === 'string' ? evidenceBlock.grade : undefined; | ||
| const evidenceLimitations = evidenceBlock && Array.isArray(evidenceBlock.limitations) ? evidenceBlock.limitations : undefined; | ||
| const entitlementVerifiedByRuntime = Boolean(evidenceBlock?.claims?.entitlement_verified_by_runtime); | ||
| const entitlementAttested = entitlementVerifiedByRuntime | ||
| || Boolean(evidenceBlock?.claims?.entitlement_attested); | ||
| // Normalize upstream error codes to our canonical registry. | ||
@@ -197,2 +207,6 @@ let normalizedError; | ||
| payloadFields, | ||
| evidenceGrade, | ||
| evidenceLimitations, | ||
| entitlementAttested, | ||
| entitlementVerifiedByRuntime, | ||
| hash: result.hash, | ||
@@ -199,0 +213,0 @@ }; |
@@ -53,2 +53,3 @@ /** | ||
| } from '../util/voprf-crypto.js'; | ||
| import { verifyBrassV2 } from '../util/brass-v2.js'; | ||
@@ -87,2 +88,11 @@ /** | ||
| export async function verifyVoprfToken(input, opts = {}) { | ||
| // Canonical BRASS 2.0 uses length-prefixed transcripts and {c,z} DLEQs. | ||
| // Keep the legacy production dialect below for existing tokens, but never | ||
| // reinterpret a v2 proof with the old plain-concat verifier. | ||
| if (input?.protocol === 'BRASS' && input?.version === '2.0' && input?.piI?.z) { | ||
| return verifyBrassV2(input, { | ||
| issuerPublicKey: opts.issuerPublicKey, | ||
| expectedKid: opts.expectedKid, | ||
| }); | ||
| } | ||
| const algorithm = input.algorithm || 'voprf-p256-sha256'; | ||
@@ -89,0 +99,0 @@ if (algorithm !== 'voprf-p256-sha256') { |
+24
-0
@@ -59,2 +59,26 @@ /** | ||
| }, | ||
| digest_mismatch: { | ||
| code: 'digest_mismatch', | ||
| description: 'Recomputed SHA-256 of the canonical payload does not match the digest carried in the record.', | ||
| class: 'tampered', | ||
| hint: 'The payload was modified after the digest was computed, or the digest field was altered.', | ||
| }, | ||
| chain_link_mismatch: { | ||
| code: 'chain_link_mismatch', | ||
| description: 'A chained record references a parent digest or leg that does not match the rest of the bundle.', | ||
| class: 'tampered', | ||
| hint: 'Each record may still be individually authentic; this code means a cross-record link is inconsistent. Check the [chain] entries in the error list.', | ||
| }, | ||
| key_mismatch: { | ||
| code: 'key_mismatch', | ||
| description: 'The verification key carried in the record does not match the key pinned with --key.', | ||
| class: 'tampered', | ||
| hint: 'The record was signed by a different identity than the one you required.', | ||
| }, | ||
| schema_invalid: { | ||
| code: 'schema_invalid', | ||
| description: 'The signed payload does not satisfy the required semantic schema for its declared receipt type.', | ||
| class: 'tampered', | ||
| hint: 'The signature may be authentic, but the record is not a valid instance of the declared contract.', | ||
| }, | ||
@@ -61,0 +85,0 @@ // --- Undecidable (exit 2) --- |
+424
-0
@@ -156,2 +156,22 @@ /** | ||
| if (result.evidenceGrade) { | ||
| const GRADE_PROVES = { | ||
| 'signed': 'integrity only; does not prove the action ran elsewhere', | ||
| 'policy-bound': 'bound to the committed policy verdict', | ||
| 'device-authorized': 'a paired-device signature authorized the action', | ||
| 'runtime-and-output-bound': 'execution profile + output hashes bound in', | ||
| 'externally-corroborated': 'an independent external system confirmed the outcome', | ||
| }; | ||
| const proves = GRADE_PROVES[result.evidenceGrade] || ''; | ||
| lines.push(` ${bold('Evidence:')} ${result.evidenceGrade}${proves ? ` ${dim(`(${proves})`)}` : ''}`); | ||
| for (const lim of (result.evidenceLimitations || [])) { | ||
| lines.push(` ${yellow('!')} ${dim(lim)}`); | ||
| } | ||
| } | ||
| if (result.entitlementVerifiedByRuntime) { | ||
| lines.push(` ${bold('Entitlement:')} pinned issuer proof verified by the receipt runtime ${dim('(full blinded proof withheld from normal receipt sync)')}`); | ||
| } else if (result.entitlementAttested) { | ||
| lines.push(` ${bold('Entitlement:')} legacy entitlement claim present ${dim('(this verifier did not independently verify the issuer proof)')}`); | ||
| } | ||
| if (result.hash) lines.push(` Hash: ${dim(result.hash)}`); | ||
@@ -193,2 +213,406 @@ | ||
| /** | ||
| * Render the salient-field lines for a recognized macro snapshot summary. | ||
| * | ||
| * @param {Object} s macroSummary object from engines/macro-snapshot.js | ||
| * @returns {string[]} | ||
| */ | ||
| function macroSummaryLines(s) { | ||
| const lines = []; | ||
| if (s.description) lines.push(` Snapshot: ${dim(s.description)}`); | ||
| if (s.as_of) lines.push(` As of: ${dim(s.as_of)}`); | ||
| switch (s.schema) { | ||
| case 'scopeblind.macro.market-state/1': | ||
| lines.push(` Class: ${s.classification}${s.confidence !== undefined ? dim(` (confidence ${s.confidence})`) : ''}`); | ||
| if (s.pillars) lines.push(` Pillars: ${dim(Object.entries(s.pillars).map(([k, v]) => `${k} ${v >= 0 ? '+' : ''}${v}`).join(', '))}`); | ||
| break; | ||
| case 'scopeblind.macro.regime-snapshot/1': | ||
| lines.push(` Regime: ${s.regime}${s.candidate_regime && s.candidate_regime !== s.regime ? dim(` (candidate ${s.candidate_regime})`) : ''}`); | ||
| if (s.liquidity_overlay) lines.push(` Liquidity: ${s.liquidity_overlay}`); | ||
| if (s.confidence !== undefined) lines.push(` Confidence: ${dim(String(s.confidence))}`); | ||
| break; | ||
| case 'scopeblind.macro.tape-snapshot/1': | ||
| lines.push(` Tape type: ${s.tape_type}${s.material !== undefined ? dim(` (${s.material ? 'material' : 'immaterial'})`) : ''}`); | ||
| if (s.coherence !== undefined) lines.push(` Coherence: ${dim(String(s.coherence))}`); | ||
| if (s.attribution_tier) lines.push(` Attributed: ${dim(s.attribution_tier)}`); | ||
| break; | ||
| case 'scopeblind.macro.vulnerability/1': | ||
| if (s.regime || s.market_state) lines.push(` Posture: ${dim([s.regime, s.market_state].filter(Boolean).join(' / '))}`); | ||
| if (Array.isArray(s.top_vulnerabilities) && s.top_vulnerabilities.length) { | ||
| lines.push(` Top risk: ${dim(s.top_vulnerabilities.map((v) => `${v.factor} (pain ${v.pain})`).join(', '))}`); | ||
| } | ||
| break; | ||
| case 'scopeblind.macro.alert/1': | ||
| lines.push(` Severity: ${s.severity}${s.kind ? dim(` (${s.kind})`) : ''}`); | ||
| if (s.title) lines.push(` Title: ${s.title}`); | ||
| break; | ||
| case 'scopeblind.macro.journal-entry/1': | ||
| if (s.author) lines.push(` Author: ${s.author}`); | ||
| lines.push(` References: ${dim(`${s.reference_count} signed snapshot(s)`)}`); | ||
| break; | ||
| case 'scopeblind.macro.track-record-manifest/1': | ||
| lines.push(` Inventory: ${dim(`${s.snapshot_count} snapshots, ${s.journal_count} journal entries`)}`); | ||
| break; | ||
| case 'scopeblind.macro.transparency-head/1': | ||
| if (s.log_id) lines.push(` Log: ${dim(s.log_id)}`); | ||
| lines.push(` Tree size: ${dim(`${s.tree_size} leaves`)}`); | ||
| if (s.root_hash) lines.push(` Root: ${dim(`${String(s.root_hash).slice(0, 16)}...`)}`); | ||
| break; | ||
| case 'scopeblind.macro.transparency-witness/1': | ||
| if (s.head_digest) lines.push(` Head: ${dim(`${String(s.head_digest).slice(0, 16)}...`)}`); | ||
| lines.push(` Tree size: ${dim(`${s.tree_size} leaves`)}`); | ||
| break; | ||
| default: | ||
| break; | ||
| } | ||
| return lines; | ||
| } | ||
| /** | ||
| * Format a ScopeBlind Gate receipt-tuple result. | ||
| * | ||
| * The report states what a VALID result proves (authenticity and | ||
| * integrity relative to the carried key) and what it does not | ||
| * (correct computation, external corroboration, signer identity). | ||
| * | ||
| * @param {Object} result from src/engines/gate-receipt.js | ||
| * @param {Object} opts cli options | ||
| * @returns {string} | ||
| */ | ||
| export function formatGateTupleResult(result, opts = {}) { | ||
| const lines = []; | ||
| if (result.valid && result.publicKey && result.publicKey.length === 64 && !isCI && !opts.noSigil) { | ||
| lines.push(''); | ||
| lines.push(renderTerminalSigil(result.publicKey)); | ||
| } | ||
| const icon = result.valid ? green('✓') : red('✗'); | ||
| const status = result.valid ? green('VALID') : red('INVALID'); | ||
| lines.push(`\n${icon} Signature: ${status}`); | ||
| lines.push(` Format: ${result.macroSchema ? 'ScopeBlind macro-engine snapshot' : 'ScopeBlind Gate receipt tuple'}`); | ||
| if (result.schema) { | ||
| const recog = result.macroSchema && result.schemaRecognized | ||
| ? dim('(recognized macro schema)') | ||
| : result.schemaRecognized | ||
| ? dim('(recognized)') | ||
| : yellow('(unrecognized schema; verified as a generic tuple)'); | ||
| lines.push(` Schema: ${result.schema} ${recog}`); | ||
| } else { | ||
| lines.push(` Schema: ${yellow('(none; verified as a generic tuple)')}`); | ||
| } | ||
| if (result.macroSummary) { | ||
| for (const line of macroSummaryLines(result.macroSummary)) lines.push(line); | ||
| } | ||
| if (result.algorithm) lines.push(` Algorithm: ${result.algorithm} (over the SHA-256 payload digest)`); | ||
| if (result.digest) lines.push(` Digest: ${dim(result.digest)}`); | ||
| if (result.recomputedDigest) lines.push(` Recomputed: ${red(result.recomputedDigest)}`); | ||
| if (result.publicKey) lines.push(` Signer: ${result.signerLabel ? bold(result.signerLabel) + ' ' : ''}${dim(result.publicKey)}`); | ||
| if (result.keySource) lines.push(` Key: ${result.keySource}`); | ||
| const pf = result.payloadFields || {}; | ||
| if (pf.decision) lines.push(` Decision: ${pf.decision}`); | ||
| if (pf.proposal) { | ||
| const p = [pf.proposal.side, pf.proposal.qty, pf.proposal.symbol].filter((v) => v !== undefined).join(' '); | ||
| lines.push(` Proposal: ${p}`); | ||
| } | ||
| if (pf.symbol && !pf.proposal) lines.push(` Instrument: ${[pf.side, pf.qty ?? pf.qty_ordered, pf.symbol].filter((v) => v !== undefined).join(' ')}`); | ||
| if (pf.batch_id) lines.push(` Batch: ${pf.batch_id}${pf.leg_count !== undefined ? dim(` (${pf.leg_count} legs)`) : ''}`); | ||
| if (pf.status) lines.push(` Status: ${pf.status}${pf.qty_filled !== undefined ? dim(` (${pf.qty_filled}/${pf.qty_ordered} filled)`) : ''}`); | ||
| if (pf.approver) lines.push(` Approver: ${pf.approver}`); | ||
| if (pf.source) lines.push(` Source: ${pf.source}`); | ||
| if (pf.mandate_name) lines.push(` Mandate: ${pf.mandate_name}${pf.mandate_digest ? dim(` (${String(pf.mandate_digest).slice(0, 16)}...)`) : ''}`); | ||
| if (pf.evaluated_at) lines.push(` Evaluated: ${dim(pf.evaluated_at)}`); | ||
| if (pf.decided_at) lines.push(` Decided: ${dim(pf.decided_at)}`); | ||
| if (pf.filled_at) lines.push(` Filled: ${dim(pf.filled_at)}`); | ||
| if (result.chainFields) { | ||
| lines.push(` ${bold('Chain links carried (not checked standalone):')}`); | ||
| for (const [k, v] of Object.entries(result.chainFields)) { | ||
| lines.push(` ${dim(`${k}: ${v}`)}`); | ||
| } | ||
| lines.push(` ${dim('Verify the evidence bundle to check these links against their parents.')}`); | ||
| } | ||
| if (result.error && !result.valid) { | ||
| lines.push(` Error: ${red(result.error)}`); | ||
| if (result.detail) lines.push(` Detail: ${yellow(result.detail)}`); | ||
| if (result.errorMeta?.hint) lines.push(` Hint: ${yellow(result.errorMeta.hint)}`); | ||
| } | ||
| if (result.valid && Array.isArray(result.proves)) { | ||
| lines.push(` ${bold('This proves:')}`); | ||
| for (const p of result.proves) lines.push(` ${green('•')} ${dim(p)}`); | ||
| lines.push(` ${bold('This does not prove:')}`); | ||
| for (const l of (result.limitations || [])) lines.push(` ${yellow('!')} ${dim(l)}`); | ||
| } | ||
| lines.push(''); | ||
| lines.push(WAYFINDING); | ||
| lines.push(''); | ||
| return lines.join('\n'); | ||
| } | ||
| /** | ||
| * Format a Legate governed-receipt result: the flat, pipe-delimited action | ||
| * receipt co-signed by the desktop daemon and the iPhone. The whole point is | ||
| * that this is re-verified by the OPEN tool over the exact signed bytes, so the | ||
| * output leads with the signer and the action identity, names what the kind is, | ||
| * and is explicit about what an embedded key does and does not prove. | ||
| * | ||
| * @param {Object} result from src/engines/legate-governed-receipt.js | ||
| * @param {Object} opts cli options | ||
| * @returns {string} | ||
| */ | ||
| export function formatGovernedReceiptResult(result, opts = {}) { | ||
| const lines = []; | ||
| if (result.valid && result.publicKey && result.publicKey.length === 64 && !isCI && !opts.noSigil) { | ||
| lines.push(''); | ||
| lines.push(renderTerminalSigil(result.publicKey)); | ||
| } | ||
| const icon = result.valid ? green('✓') : red('✗'); | ||
| const status = result.valid ? green('VALID') : red('INVALID'); | ||
| lines.push(`\n${icon} Signature: ${status}`); | ||
| lines.push(` Format: Legate governed receipt`); | ||
| const kindLabel = result.kindRecognized ? `${result.kind} ${dim('(recognized governed action)')}` : `${result.kind} ${yellow('(unrecognized action kind)')}`; | ||
| lines.push(` Action: ${kindLabel}`); | ||
| const pf = result.payloadFields || {}; | ||
| if (pf.tool) lines.push(` Tool: ${pf.tool}`); | ||
| if (pf.decision) lines.push(` Decision: ${pf.decision}`); | ||
| if (pf.id) lines.push(` Receipt id: ${dim(pf.id)}`); | ||
| if (pf.input_sha256) lines.push(` Input: ${dim(`sha256:${String(pf.input_sha256).slice(0, 16)}…`)}`); | ||
| if (pf.result_sha256) lines.push(` Result: ${dim(`sha256:${String(pf.result_sha256).slice(0, 16)}…`)}`); | ||
| if (pf.at) lines.push(` Signed at: ${dim(pf.at)}`); | ||
| if (result.algorithm) lines.push(` Algorithm: ${result.algorithm} ${dim('(over the canonical action payload, not a digest)')}`); | ||
| if (result.publicKey) lines.push(` Signer: ${result.signerLabel ? bold(result.signerLabel) + ' ' : ''}${dim(result.publicKey)}`); | ||
| if (result.keySource) lines.push(` Key: ${result.keySource}`); | ||
| if (result.error && !result.valid) { | ||
| lines.push(` Error: ${red(result.error)}`); | ||
| if (result.error === 'invalid_signature') lines.push(` Detail: ${yellow('the signed bytes were altered or the key does not match — this receipt was tampered with')}`); | ||
| else if (result.detail) lines.push(` Detail: ${yellow(result.detail)}`); | ||
| if (result.expectedKey) lines.push(` Expected: ${yellow(result.expectedKey)} ${dim('(--key)')}`); | ||
| } | ||
| if (result.valid && Array.isArray(result.proves)) { | ||
| lines.push(` ${bold('This proves:')}`); | ||
| for (const p of result.proves) lines.push(` ${green('•')} ${dim(p)}`); | ||
| lines.push(` ${bold('This does not prove:')}`); | ||
| for (const l of (result.limitations || [])) lines.push(` ${yellow('!')} ${dim(l)}`); | ||
| } | ||
| lines.push(''); | ||
| lines.push(WAYFINDING); | ||
| lines.push(''); | ||
| return lines.join('\n'); | ||
| } | ||
| /** | ||
| * Format a Trusted Context Pack result: the signed attestation that a source file | ||
| * parsed to this context at this confidence and freshness. The output leads with | ||
| * the signer and the gate decision (usable / needs approval / blocked), names the | ||
| * source type, and is explicit that this attests the parse, not the provenance. | ||
| * | ||
| * @param {Object} result from src/engines/trusted-context-pack.js | ||
| * @param {Object} opts cli options | ||
| * @returns {string} | ||
| */ | ||
| export function formatTrustedContextPackResult(result, opts = {}) { | ||
| const lines = []; | ||
| if (result.valid && result.publicKey && result.publicKey.length === 64 && !isCI && !opts.noSigil) { | ||
| lines.push(''); | ||
| lines.push(renderTerminalSigil(result.publicKey)); | ||
| } | ||
| const icon = result.valid ? green('✓') : red('✗'); | ||
| const status = result.valid ? green('VALID') : red('INVALID'); | ||
| lines.push(`\n${icon} Signature: ${status}`); | ||
| lines.push(` Format: ScopeBlind Trusted Context Pack`); | ||
| if (result.schema) { | ||
| lines.push(` Schema: ${result.schema} ${result.schemaRecognized ? dim('(recognized)') : yellow('(unrecognized)')}`); | ||
| } | ||
| const pf = result.payloadFields || {}; | ||
| if (pf.source_type) lines.push(` Source: ${pf.source_type}${pf.source_format ? dim(` (${pf.source_format})`) : ''}`); | ||
| if (pf.file_name) lines.push(` File: ${pf.file_name}`); | ||
| if (pf.file_hash) lines.push(` File hash: ${dim(`sha256:${String(pf.file_hash).slice(0, 16)}…`)}`); | ||
| // The gate decision is the headline: usable feeds a decision, the others do not. | ||
| const gate = result.gateStatus || pf.gate_status; | ||
| if (gate) { | ||
| const tone = gate === 'usable' ? green(gate) : gate === 'needs_approval' ? yellow(gate) : red(gate); | ||
| const note = gate === 'usable' ? 'may feed a gate decision' | ||
| : gate === 'needs_approval' ? 'held: requires explicit human approval before use' | ||
| : 'blocked: cannot feed a decision'; | ||
| lines.push(` Gate: ${tone} ${dim(`(${note})`)}`); | ||
| } | ||
| if (result.confidence !== undefined) lines.push(` Confidence: ${result.confidence}`); | ||
| if (pf.freshness) { | ||
| const dated = pf.freshness.as_of ? `as of ${String(pf.freshness.as_of).slice(0, 10)}` : yellow('no as-of date'); | ||
| const fresh = pf.freshness.stale ? red('(stale)') : dim('(fresh)'); | ||
| lines.push(` Freshness: ${dated} ${fresh}`); | ||
| } | ||
| if (Array.isArray(pf.warnings) && pf.warnings.length) { | ||
| lines.push(` ${bold('Warnings:')}`); | ||
| for (const w of pf.warnings) lines.push(` ${yellow('!')} ${dim(w)}`); | ||
| } | ||
| if (result.algorithm) lines.push(` Algorithm: ${result.algorithm} ${dim('(over the SHA-256 payload digest)')}`); | ||
| if (result.digest) lines.push(` Digest: ${dim(result.digest)}`); | ||
| if (result.recomputedDigest) lines.push(` Recomputed: ${red(result.recomputedDigest)}`); | ||
| if (result.publicKey) lines.push(` Signer: ${result.signerLabel ? bold(result.signerLabel) + ' ' : ''}${dim(result.publicKey)}`); | ||
| if (result.keySource) lines.push(` Key: ${result.keySource}`); | ||
| if (result.error && !result.valid) { | ||
| lines.push(` Error: ${red(result.error)}`); | ||
| if (result.error === 'invalid_signature') lines.push(` Detail: ${yellow('the signed bytes were altered or the key does not match; this pack was tampered with')}`); | ||
| else if (result.detail) lines.push(` Detail: ${yellow(result.detail)}`); | ||
| if (result.expectedKey) lines.push(` Expected: ${yellow(result.expectedKey)} ${dim('(--key)')}`); | ||
| } | ||
| if (result.valid && Array.isArray(result.proves)) { | ||
| lines.push(` ${bold('This proves:')}`); | ||
| for (const p of result.proves) lines.push(` ${green('•')} ${dim(p)}`); | ||
| lines.push(` ${bold('This does not prove:')}`); | ||
| for (const l of (result.limitations || [])) lines.push(` ${yellow('!')} ${dim(l)}`); | ||
| } | ||
| lines.push(''); | ||
| lines.push(WAYFINDING); | ||
| lines.push(''); | ||
| return lines.join('\n'); | ||
| } | ||
| /** | ||
| * Format a ScopeBlind Gate evidence-bundle result. Crypto failures | ||
| * and chain failures are reported separately so a reader can tell | ||
| * "this record was forged or modified" apart from "this record is | ||
| * authentic but points at the wrong parent". | ||
| * | ||
| * @param {Object} result from src/engines/gate-receipt.js | ||
| * @param {Object} opts cli options | ||
| * @returns {string} | ||
| */ | ||
| export function formatGateBundleResult(result, opts = {}) { | ||
| const lines = []; | ||
| const icon = result.valid ? green('✓') : red('✗'); | ||
| const status = result.valid ? green('VALID') : red('INVALID'); | ||
| lines.push(`\n${icon} Gate evidence bundle: ${status}`); | ||
| lines.push(` Schema: ${result.schema || '(missing)'}`); | ||
| if (result.exportedAt) lines.push(` Exported: ${dim(result.exportedAt)}`); | ||
| lines.push(` Entries: ${result.entryCount}`); | ||
| lines.push(` Records: ${result.total} (${green(String(result.passed))} passed, ${result.failed > 0 ? red(String(result.failed)) : '0'} failed)`); | ||
| const cryptoOk = result.total - result.cryptoFailed; | ||
| lines.push(` Signatures: ${result.cryptoFailed > 0 ? red(`${cryptoOk}/${result.total} valid`) : green(`${cryptoOk}/${result.total} valid`)}`); | ||
| const chainOk = result.chainChecks - result.chainFailed; | ||
| lines.push(` Chain links: ${result.chainFailed > 0 ? red(`${chainOk}/${result.chainChecks} consistent`) : green(`${chainOk}/${result.chainChecks} consistent`)}`); | ||
| if (result.manifestValid !== null) lines.push(` Signed manifest: ${result.manifestValid ? green('valid and exact') : red('missing or inconsistent')}`); | ||
| const unrecognized = (result.records || []).filter((r) => !r.schemaRecognized); | ||
| if (unrecognized.length > 0) { | ||
| lines.push(` ${yellow('Unrecognized schemas:')} ${[...new Set(unrecognized.map((r) => r.schema || '(none)'))].join(', ')} ${dim('(verified as generic tuples)')}`); | ||
| } | ||
| if (Array.isArray(result.signers) && result.signers.length > 0) { | ||
| lines.push(` ${bold('Signing keys seen:')}`); | ||
| for (const s of result.signers) { | ||
| const tag = s.isGateKey ? green('gate key') : yellow('other key'); | ||
| lines.push(` ${dim(s.key)} ${tag} ${dim(`(${s.roles.join(', ')})`)}`); | ||
| } | ||
| } | ||
| if (result.entriesUseGateKey !== null) { | ||
| lines.push(` Entry receipts signed by gate_verification_key: ${result.entriesUseGateKey ? green('yes') : red('no')}`); | ||
| } | ||
| if (Array.isArray(result.errors) && result.errors.length > 0) { | ||
| lines.push(`\n ${red('Failures:')} ${dim('([crypto] record forged or modified; [chain] record authentic but link inconsistent)')}`); | ||
| for (const e of result.errors) lines.push(` ${red('•')} ${e}`); | ||
| } | ||
| if (result.valid) { | ||
| lines.push(''); | ||
| lines.push(` ${bold('This proves:')}`); | ||
| for (const p of (result.proves || [])) lines.push(` ${green('•')} ${dim(p)}`); | ||
| lines.push(` ${bold('This does not prove:')}`); | ||
| for (const l of (result.limitations || [])) lines.push(` ${yellow('!')} ${dim(l)}`); | ||
| } | ||
| lines.push(''); | ||
| lines.push(WAYFINDING); | ||
| lines.push(''); | ||
| return lines.join('\n'); | ||
| } | ||
| /** | ||
| * Format a ScopeBlind macro-engine track-record bundle result. Like the Gate | ||
| * bundle formatter, crypto failures and chain (single-signer / manifest / | ||
| * history) failures are reported separately. | ||
| * | ||
| * @param {Object} result from src/engines/macro-snapshot.js | ||
| * @param {Object} opts cli options | ||
| * @returns {string} | ||
| */ | ||
| export function formatMacroTrackRecordResult(result, opts = {}) { | ||
| const lines = []; | ||
| const icon = result.valid ? green('✓') : red('✗'); | ||
| const status = result.valid ? green('VALID') : red('INVALID'); | ||
| lines.push(`\n${icon} Macro track-record bundle: ${status}`); | ||
| lines.push(` Schema: ${result.schema || '(missing)'}`); | ||
| if (result.exportedAt) lines.push(` Exported: ${dim(result.exportedAt)}`); | ||
| if (result.period) lines.push(` Period: ${dim(`${result.period.from} → ${result.period.to}`)}`); | ||
| if (result.custody) lines.push(` Custody: ${dim(result.custody)}`); | ||
| lines.push(` Identity: ${result.signerPinned ? green('pinned expected key') : yellow('embedded key only — integrity, not operator identity')}`); | ||
| if (result.sequence) lines.push(` Sequence: ${result.sequence}`); | ||
| lines.push(` Snapshots: ${result.snapshotCount}`); | ||
| lines.push(` Journal: ${result.journalCount}`); | ||
| lines.push(` Records: ${result.total} (${green(String(result.passed))} passed, ${result.failed > 0 ? red(String(result.failed)) : '0'} failed)`); | ||
| const cryptoOk = result.total - result.cryptoFailed; | ||
| lines.push(` Signatures: ${result.cryptoFailed > 0 ? red(`${cryptoOk}/${result.total} valid`) : green(`${cryptoOk}/${result.total} valid`)}`); | ||
| const chainOk = result.chainChecks - result.chainFailed; | ||
| lines.push(` Chain links: ${result.chainFailed > 0 ? red(`${chainOk}/${result.chainChecks} consistent`) : green(`${chainOk}/${result.chainChecks} consistent`)}`); | ||
| if (result.manifestValid !== null) lines.push(` Signed manifest: ${result.manifestValid ? green('valid and exact') : red('missing or inconsistent')}`); | ||
| lines.push(` Single-key consistency: ${result.singleSigner ? green('yes') : red('no')}`); | ||
| lines.push(` Append-only history: ${result.historyChainValid === true ? green('linked and retained') : result.historyChainValid === false ? red('invalid') : yellow('not established by this export')}`); | ||
| lines.push(` Signed checkpoint: ${result.historyAnchored ? green('present') : yellow('absent')}`); | ||
| if (result.transparencyAnchor) { | ||
| const t = result.transparencyAnchor; | ||
| const leaves = t.tree_size !== null ? `${t.tree_size} leaves` : 'Merkle log'; | ||
| if (t.anchor === 'witnessed') lines.push(` Transparency: ${green(`witness-anchored (Merkle log, ${leaves})`)}`); | ||
| else if (t.anchor === 'self_signed') lines.push(` Transparency: ${yellow(`self-signed (Merkle log, ${leaves})`)}`); | ||
| else lines.push(` Transparency: ${red('not anchored')}`); | ||
| } | ||
| if (result.historyHeadPinned) lines.push(` History head: ${green('matches independently pinned head')}`); | ||
| if (result.anchorHeadPinned) lines.push(` Anchor head: ${green('matches independently pinned head')}`); | ||
| if (Array.isArray(result.signers) && result.signers.length > 0) { | ||
| lines.push(` ${bold('Signing keys seen:')}`); | ||
| for (const s of result.signers) { | ||
| const tag = s.isModelKey ? green('model key') : yellow('other key'); | ||
| lines.push(` ${dim(s.key)} ${tag} ${dim(`(${s.roles.join(', ')})`)}`); | ||
| } | ||
| } | ||
| if (Array.isArray(result.errors) && result.errors.length > 0) { | ||
| lines.push(`\n ${red('Failures:')} ${dim('([crypto] record forged or modified; [chain] custody, manifest, or history-head inconsistent)')}`); | ||
| for (const e of result.errors) lines.push(` ${red('•')} ${e}`); | ||
| } | ||
| if (result.valid) { | ||
| lines.push(''); | ||
| lines.push(` ${bold('This proves:')}`); | ||
| for (const p of (result.proves || [])) lines.push(` ${green('•')} ${dim(p)}`); | ||
| lines.push(` ${bold('This does not prove:')}`); | ||
| for (const l of (result.limitations || [])) lines.push(` ${yellow('!')} ${dim(l)}`); | ||
| } | ||
| lines.push(''); | ||
| lines.push(WAYFINDING); | ||
| lines.push(''); | ||
| return lines.join('\n'); | ||
| } | ||
| export function formatKuResult(result, opts = {}) { | ||
@@ -195,0 +619,0 @@ const lines = []; |
+20
-89
| /** | ||
| * JWKS (JSON Web Key Set) resolution utility. | ||
| * | ||
| * Resolves a JWKS from HTTP(S), file://, or a bare filesystem path and | ||
| * extracts an Ed25519 public key for a given kid, returning the hex-encoded | ||
| * raw key for use by the receipt verifier. | ||
| * Fetches a JWKS from a URL and extracts an Ed25519 public key for | ||
| * a given kid, returning the hex-encoded raw key for use by the | ||
| * receipt verifier. | ||
| * | ||
| * Network resolution is opt-in: only HTTP(S) JWKS locators call fetch. | ||
| * Bare paths and file:// URLs are resolved from local disk for offline CI | ||
| * and test-vector workflows. | ||
| * This is the only path in the verifier that may make a network | ||
| * request. It is opt-in: the caller must pass --jwks <url>. | ||
| * | ||
@@ -21,6 +20,2 @@ * References: | ||
| import { readFile } from 'node:fs/promises'; | ||
| import { isAbsolute, resolve } from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { base64urlToBytes, bytesToHex } from './hex.js'; | ||
@@ -30,83 +25,23 @@ | ||
| * @typedef {Object} JwksResolveResult | ||
| * @property {string|null} key hex-encoded raw key, or null on failure | ||
| * @property {string|null} key hex-encoded raw key, or null on failure | ||
| * @property {string|null} error | ||
| * @property {string} [kid] | ||
| * @property {{type: 'http'|'file', locator: string, resolved?: string}} [source] | ||
| */ | ||
| /** | ||
| * Resolve a JWKS locator to parsed JSON. | ||
| * | ||
| * @param {string} locator HTTP(S) URL, file:// URL, or filesystem path | ||
| * @returns {Promise<{jwks: Object|null, error: string|null, source?: JwksResolveResult['source']}>} | ||
| */ | ||
| async function loadJwks(locator) { | ||
| if (typeof locator !== 'string' || locator.trim() === '') { | ||
| return { jwks: null, error: 'JWKS locator is empty' }; | ||
| } | ||
| const raw = locator.trim(); | ||
| let parsed; | ||
| try { | ||
| parsed = new URL(raw); | ||
| } catch { | ||
| parsed = null; | ||
| } | ||
| if (parsed?.protocol === 'http:' || parsed?.protocol === 'https:') { | ||
| try { | ||
| const response = await fetch(raw); | ||
| if (!response.ok) { | ||
| return { jwks: null, error: `JWKS fetch failed: HTTP ${response.status}` }; | ||
| } | ||
| return { | ||
| jwks: await response.json(), | ||
| error: null, | ||
| source: { type: 'http', locator: raw }, | ||
| }; | ||
| } catch (e) { | ||
| return { jwks: null, error: `JWKS fetch error: ${e.message}` }; | ||
| } | ||
| } | ||
| let path; | ||
| try { | ||
| if (parsed?.protocol === 'file:') { | ||
| path = fileURLToPath(parsed); | ||
| } else if (parsed?.protocol) { | ||
| return { jwks: null, error: `Unsupported JWKS URL scheme: ${parsed.protocol}` }; | ||
| } else { | ||
| path = raw; | ||
| } | ||
| } catch (e) { | ||
| return { jwks: null, error: `JWKS file URL parse error: ${e.message}` }; | ||
| } | ||
| const resolved = isAbsolute(path) ? path : resolve(process.cwd(), path); | ||
| try { | ||
| const text = await readFile(resolved, 'utf8'); | ||
| return { | ||
| jwks: JSON.parse(text), | ||
| error: null, | ||
| source: { type: 'file', locator: raw, resolved }, | ||
| }; | ||
| } catch (e) { | ||
| return { jwks: null, error: `JWKS file read error (${resolved}): ${e.message}` }; | ||
| } | ||
| } | ||
| /** | ||
| * Resolve a JWKS locator and return the Ed25519 public key matching kid. | ||
| * Fetch a JWKS endpoint and return the Ed25519 public key matching kid. | ||
| * If kid is not supplied, returns the first Ed25519 key found. | ||
| * | ||
| * @param {string} locator HTTP(S) URL, file:// URL, or filesystem path | ||
| * @param {string} url | ||
| * @param {string} [kid] | ||
| * @returns {Promise<JwksResolveResult>} | ||
| */ | ||
| export async function resolveFromJwks(locator, kid) { | ||
| const loaded = await loadJwks(locator); | ||
| if (!loaded.jwks) return { key: null, error: loaded.error }; | ||
| export async function resolveFromJwks(url, kid) { | ||
| try { | ||
| const keys = Array.isArray(loaded.jwks.keys) ? loaded.jwks.keys : []; | ||
| const response = await fetch(url); | ||
| if (!response.ok) { | ||
| return { key: null, error: `JWKS fetch failed: HTTP ${response.status}` }; | ||
| } | ||
| const jwks = await response.json(); | ||
| const keys = Array.isArray(jwks.keys) ? jwks.keys : []; | ||
@@ -116,21 +51,17 @@ let jwk; | ||
| jwk = keys.find((k) => k.kid === kid); | ||
| if (!jwk) return { key: null, error: `No key with kid "${kid}" in JWKS`, source: loaded.source }; | ||
| if (!jwk) return { key: null, error: `No key with kid "${kid}" in JWKS` }; | ||
| } else { | ||
| jwk = keys.find((k) => k.kty === 'OKP' && k.crv === 'Ed25519'); | ||
| if (!jwk) return { key: null, error: 'No Ed25519 key found in JWKS', source: loaded.source }; | ||
| if (!jwk) return { key: null, error: 'No Ed25519 key found in JWKS' }; | ||
| } | ||
| if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') { | ||
| return { | ||
| key: null, | ||
| error: `Key "${kid}" is not Ed25519 (kty=${jwk.kty}, crv=${jwk.crv})`, | ||
| source: loaded.source, | ||
| }; | ||
| return { key: null, error: `Key "${kid}" is not Ed25519 (kty=${jwk.kty}, crv=${jwk.crv})` }; | ||
| } | ||
| const raw = base64urlToBytes(jwk.x); | ||
| return { key: bytesToHex(raw), error: null, kid: jwk.kid, source: loaded.source }; | ||
| return { key: bytesToHex(raw), error: null, kid: jwk.kid }; | ||
| } catch (e) { | ||
| return { key: null, error: `JWKS parse error: ${e.message}`, source: loaded.source }; | ||
| return { key: null, error: `JWKS fetch error: ${e.message}` }; | ||
| } | ||
| } |
| /** | ||
| * @veritasacta/verify — adapters/kv-store.js | ||
| * | ||
| * Cloudflare KV-backed counter store for BRASS verifiers. | ||
| * | ||
| * ⚠️ Eventually consistent — users CAN bypass quotas by hitting different | ||
| * edge locations within the replication window (~60s). Overspend is bounded | ||
| * by (E - 1) * λ * w where E = edge count, λ = per-edge rate, w = replication lag. | ||
| * | ||
| * Suitable for: free tiers, low-stakes rate limiting, shadow/observe mode. | ||
| * For strict enforcement, use Durable Objects or any CAS-capable backend. | ||
| * | ||
| * @module @veritasacta/verify/adapters/kv | ||
| * @license MIT | ||
| */ | ||
| import { BrassCounterStore } from '../storage.js'; | ||
| export class KVStore extends BrassCounterStore { | ||
| /** | ||
| * @param {KVNamespace} kvNamespace - Cloudflare KV namespace binding | ||
| */ | ||
| constructor(kvNamespace) { | ||
| super(); | ||
| this.kv = kvNamespace; | ||
| } | ||
| async spend({ counterKey, idempotencyKey, limit, ttlSeconds }) { | ||
| const ikKey = `ik:${idempotencyKey}`; | ||
| // 1. Idempotency check | ||
| const existing = await this.kv.get(ikKey, 'json'); | ||
| if (existing) { | ||
| return { ...existing, idempotent: true }; | ||
| } | ||
| // 2. Read counter (eventually consistent) | ||
| const data = await this.kv.get(counterKey, 'json'); | ||
| const currentValue = data ? parseFloat(data.value || 0) : 0; | ||
| // 3. Threshold check | ||
| if (currentValue >= limit) { | ||
| const deny = { ok: false, error: 'rate_limited', remaining: 0 }; | ||
| await this.kv.put(ikKey, JSON.stringify(deny), { expirationTtl: ttlSeconds }); | ||
| return deny; | ||
| } | ||
| // 4. Increment counter | ||
| const newValue = currentValue + 1; | ||
| const remaining = Math.max(0, limit - newValue); | ||
| await this.kv.put(counterKey, JSON.stringify({ | ||
| value: newValue, | ||
| lastUpdated: Date.now(), | ||
| }), { expirationTtl: ttlSeconds }); | ||
| // 5. Cache response | ||
| const accept = { ok: true, remaining }; | ||
| await this.kv.put(ikKey, JSON.stringify(accept), { expirationTtl: ttlSeconds }); | ||
| return accept; | ||
| } | ||
| async guardGrace({ graceKey, ttlSeconds }) { | ||
| const key = `grace:${graceKey}`; | ||
| const cached = await this.kv.get(key, 'json'); | ||
| return cached ? { hit: true, response: cached } : { hit: false }; | ||
| } | ||
| async cacheGraceResponse({ graceKey, ttlSeconds, response }) { | ||
| await this.kv.put(`grace:${graceKey}`, JSON.stringify(response), { | ||
| expirationTtl: ttlSeconds, | ||
| }); | ||
| } | ||
| } |
| /** | ||
| * @veritasacta/verify — adapters/memory-store.js | ||
| * | ||
| * In-process counter store for testing and single-instance deployments. | ||
| * NOT suitable for distributed deployments (no cross-process synchronization). | ||
| * | ||
| * Features: | ||
| * - Automatic TTL expiry via setTimeout | ||
| * - Idempotency key deduplication | ||
| * - Grace-bridge caching | ||
| * - Zero dependencies | ||
| * | ||
| * @module @veritasacta/verify/adapters/memory | ||
| * @license MIT | ||
| */ | ||
| import { BrassCounterStore } from '../storage.js'; | ||
| export class MemoryStore extends BrassCounterStore { | ||
| constructor() { | ||
| super(); | ||
| /** @type {Map<string, {value: number, expiresAt: number}>} */ | ||
| this.counters = new Map(); | ||
| /** @type {Map<string, {response: object, expiresAt: number}>} */ | ||
| this.idempotency = new Map(); | ||
| /** @type {Map<string, {response: object, expiresAt: number}>} */ | ||
| this.graceCache = new Map(); | ||
| } | ||
| async spend({ counterKey, idempotencyKey, limit, ttlSeconds }) { | ||
| const now = Date.now(); | ||
| // Purge expired entries (lazy cleanup) | ||
| this._purge(now); | ||
| // 1. Idempotency check | ||
| const cached = this.idempotency.get(idempotencyKey); | ||
| if (cached && cached.expiresAt > now) { | ||
| return { ...cached.response, idempotent: true }; | ||
| } | ||
| // 2. Read counter | ||
| const entry = this.counters.get(counterKey); | ||
| const currentValue = (entry && entry.expiresAt > now) ? entry.value : 0; | ||
| // 3. Threshold check | ||
| if (currentValue >= limit) { | ||
| const deny = { ok: false, error: 'rate_limited', remaining: 0 }; | ||
| this.idempotency.set(idempotencyKey, { | ||
| response: deny, | ||
| expiresAt: now + ttlSeconds * 1000, | ||
| }); | ||
| return deny; | ||
| } | ||
| // 4. Increment counter | ||
| const newValue = currentValue + 1; | ||
| const remaining = Math.max(0, limit - newValue); | ||
| this.counters.set(counterKey, { | ||
| value: newValue, | ||
| expiresAt: now + ttlSeconds * 1000, | ||
| }); | ||
| // 5. Cache accept response | ||
| const accept = { ok: true, remaining }; | ||
| this.idempotency.set(idempotencyKey, { | ||
| response: accept, | ||
| expiresAt: now + ttlSeconds * 1000, | ||
| }); | ||
| return accept; | ||
| } | ||
| async guardGrace({ graceKey, ttlSeconds }) { | ||
| const now = Date.now(); | ||
| const cached = this.graceCache.get(graceKey); | ||
| if (cached && cached.expiresAt > now) { | ||
| return { hit: true, response: cached.response }; | ||
| } | ||
| return { hit: false }; | ||
| } | ||
| async cacheGraceResponse({ graceKey, ttlSeconds, response }) { | ||
| this.graceCache.set(graceKey, { | ||
| response, | ||
| expiresAt: Date.now() + ttlSeconds * 1000, | ||
| }); | ||
| } | ||
| /** Remove expired entries. Called lazily on each spend(). */ | ||
| _purge(now) { | ||
| // Only purge if maps are getting large (> 10K entries) | ||
| if (this.counters.size + this.idempotency.size < 10_000) return; | ||
| for (const [k, v] of this.counters) { | ||
| if (v.expiresAt <= now) this.counters.delete(k); | ||
| } | ||
| for (const [k, v] of this.idempotency) { | ||
| if (v.expiresAt <= now) this.idempotency.delete(k); | ||
| } | ||
| for (const [k, v] of this.graceCache) { | ||
| if (v.expiresAt <= now) this.graceCache.delete(k); | ||
| } | ||
| } | ||
| /** Clear all state (useful in tests). */ | ||
| clear() { | ||
| this.counters.clear(); | ||
| this.idempotency.clear(); | ||
| this.graceCache.clear(); | ||
| } | ||
| } |
-383
| /** | ||
| * @veritasacta/verify — crypto.js | ||
| * | ||
| * Deterministic cryptographic primitives for the BRASS protocol. | ||
| * Domain-separated hashing, nullifier derivation, salt computation, | ||
| * and window management for privacy-preserving rate limiting. | ||
| * | ||
| * All functions are pure (no side effects, no I/O). | ||
| * | ||
| * References: | ||
| * RFC 9497 — VOPRF (Verifiable Oblivious Pseudorandom Functions) | ||
| * RFC 9380 — Hashing to Elliptic Curves | ||
| * | ||
| * @module @veritasacta/verify/crypto | ||
| * @license MIT | ||
| */ | ||
| import { sha256 } from '@noble/hashes/sha256'; | ||
| import { hmac } from '@noble/hashes/hmac'; | ||
| import { utf8ToBytes } from '@noble/hashes/utils'; | ||
| // ─── Encoding helpers ─────────────────────────────────────────────────────── | ||
| /** Convert a string to UTF-8 bytes, pass Uint8Array through unchanged. */ | ||
| export function toBytes(v) { | ||
| if (v instanceof Uint8Array) return v; | ||
| if (typeof v === 'string') return utf8ToBytes(v); | ||
| if (typeof v === 'number') return numberToU32BE(v); | ||
| throw new TypeError('toBytes: expected string, Uint8Array, or number'); | ||
| } | ||
| /** Encode a number as 4-byte big-endian. */ | ||
| function numberToU32BE(n) { | ||
| const b = new Uint8Array(4); | ||
| b[0] = (n >>> 24) & 0xff; | ||
| b[1] = (n >>> 16) & 0xff; | ||
| b[2] = (n >>> 8) & 0xff; | ||
| b[3] = n & 0xff; | ||
| return b; | ||
| } | ||
| /** | ||
| * Length-prefix a byte array (4-byte BE length ∥ data). | ||
| * Prevents cross-field collisions in hash inputs. | ||
| */ | ||
| function lengthPrefix(bytes) { | ||
| const len = numberToU32BE(bytes.length); | ||
| const out = new Uint8Array(4 + bytes.length); | ||
| out.set(len, 0); | ||
| out.set(bytes, 4); | ||
| return out; | ||
| } | ||
| /** | ||
| * Concatenate multiple Uint8Arrays efficiently (single allocation). | ||
| * @param {Uint8Array[]} arrays | ||
| * @returns {Uint8Array} | ||
| */ | ||
| function concat(arrays) { | ||
| let totalLen = 0; | ||
| for (let i = 0; i < arrays.length; i++) totalLen += arrays[i].length; | ||
| const out = new Uint8Array(totalLen); | ||
| let offset = 0; | ||
| for (let i = 0; i < arrays.length; i++) { | ||
| out.set(arrays[i], offset); | ||
| offset += arrays[i].length; | ||
| } | ||
| return out; | ||
| } | ||
| // ─── Base64URL ────────────────────────────────────────────────────────────── | ||
| /** Encode bytes to base64url (no padding). */ | ||
| export function bytesToB64url(b) { | ||
| // Works in Node 18+, Cloudflare Workers, Deno, browsers | ||
| const base64 = typeof Buffer !== 'undefined' | ||
| ? Buffer.from(b).toString('base64') | ||
| : btoa(String.fromCharCode(...b)); | ||
| return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); | ||
| } | ||
| /** Decode base64url to bytes. */ | ||
| export function b64urlToBytes(s) { | ||
| if (s == null) throw new Error('b64urlToBytes: input cannot be null/undefined'); | ||
| let base64 = s.replace(/-/g, '+').replace(/_/g, '/'); | ||
| const pad = base64.length % 4; | ||
| if (pad) base64 += '='.repeat(4 - pad); | ||
| if (typeof Buffer !== 'undefined') { | ||
| return new Uint8Array(Buffer.from(base64, 'base64')); | ||
| } | ||
| const binary = atob(base64); | ||
| return Uint8Array.from(binary, c => c.charCodeAt(0)); | ||
| } | ||
| // ─── Core hash functions ──────────────────────────────────────────────────── | ||
| /** | ||
| * H3: Domain-separated, length-prefixed SHA-256. | ||
| * | ||
| * Each input is converted to bytes, length-prefixed, then concatenated. | ||
| * This prevents: | ||
| * - Cross-field collisions: H3("ab", "c") ≠ H3("a", "bc") | ||
| * - Type confusion: H3(0x01) ≠ H3("\x01") | ||
| * | ||
| * @param {...(string|Uint8Array|number)} parts - Inputs to hash | ||
| * @returns {Uint8Array} 32-byte SHA-256 digest | ||
| */ | ||
| export function H3(...parts) { | ||
| const prefixed = parts.map(p => lengthPrefix(toBytes(p))); | ||
| return sha256(concat(prefixed)); | ||
| } | ||
| /** | ||
| * H2: Nullifier derivation hash. | ||
| * Identical to H3 — separate name for protocol clarity. | ||
| * Future: may use HKDF-Expand with a distinct label. | ||
| */ | ||
| export function H2(...parts) { | ||
| return H3(...parts); | ||
| } | ||
| // ─── Origin canonicalization ──────────────────────────────────────────────── | ||
| /** | ||
| * Canonicalize a web origin for scope binding. | ||
| * | ||
| * Rules: | ||
| * - HTTPS only (rejects http://) | ||
| * - No path, query, or fragment | ||
| * - Lowercase hostname with IDNA normalization | ||
| * - Default port 443 elided | ||
| * - Trailing dots stripped | ||
| * | ||
| * @param {string} originUrl - Full origin URL (e.g., "https://api.example.com") | ||
| * @returns {string} Canonical origin (e.g., "https://api.example.com") | ||
| * @throws {Error} If origin is invalid or not HTTPS | ||
| */ | ||
| export function canonicalOrigin(originUrl) { | ||
| try { | ||
| const u = new URL(originUrl); | ||
| if (u.protocol !== 'https:') throw new Error('origin_must_be_https'); | ||
| if (u.pathname !== '/' || u.search !== '' || u.hash !== '') { | ||
| throw new Error('origin_must_not_contain_path'); | ||
| } | ||
| if (!u.hostname) throw new Error('invalid_hostname'); | ||
| const host = u.hostname.toLowerCase().replace(/\.+$/, ''); | ||
| if (!host) throw new Error('invalid_hostname'); | ||
| const isDefaultPort = (u.port === '' || u.port === '443'); | ||
| return `https://${host}${isDefaultPort ? '' : ':' + u.port}`; | ||
| } catch (e) { | ||
| if (e.message.startsWith('origin_') || e.message.startsWith('invalid_')) throw e; | ||
| throw new Error('invalid_origin'); | ||
| } | ||
| } | ||
| // ─── Time / window functions ──────────────────────────────────────────────── | ||
| /** Current UTC day index (epoch days). */ | ||
| export function currentEpochDays(nowMs = Date.now()) { | ||
| return Math.floor(nowMs / 86400000); | ||
| } | ||
| /** | ||
| * Compute window ID for a given timestamp and window duration. | ||
| * | ||
| * @param {number} nowMs - Current time in milliseconds | ||
| * @param {number} windowSec - Window duration in seconds (default: 86400 = 1 day) | ||
| * @returns {number} Window identifier | ||
| */ | ||
| export function windowId(nowMs = Date.now(), windowSec = 86400) { | ||
| return Math.floor(nowMs / (windowSec * 1000)); | ||
| } | ||
| /** Clock skew tolerance in milliseconds. */ | ||
| const WINDOW_SKEW_MS = 30_000; | ||
| /** | ||
| * Valid windows for the current time, accounting for ±30s clock skew. | ||
| * | ||
| * @param {number} nowMs - Current time in milliseconds | ||
| * @param {number} windowSec - Window duration in seconds (default: 86400) | ||
| * @returns {number[]} Array of valid window IDs (current, and optionally previous) | ||
| */ | ||
| export function validWindowsWithSkew(nowMs = Date.now(), windowSec = 86400) { | ||
| const windowMs = windowSec * 1000; | ||
| const current = Math.floor(nowMs / windowMs); | ||
| const windows = [current]; | ||
| const msIntoWindow = nowMs % windowMs; | ||
| if (msIntoWindow < WINDOW_SKEW_MS) { | ||
| windows.push(current - 1); | ||
| } | ||
| return windows; | ||
| } | ||
| /** | ||
| * Seconds remaining until the current window ends. | ||
| * | ||
| * @param {number} windowStart - Window start identifier | ||
| * @param {number} windowSec - Window duration in seconds (default: 86400) | ||
| * @returns {number} Seconds remaining (minimum 1) | ||
| */ | ||
| export function secondsUntilWindowEnd(windowStart, windowSec = 86400) { | ||
| const windowMs = windowSec * 1000; | ||
| const windowEnd = (Number(windowStart) + 1) * windowMs; | ||
| return Math.max(1, Math.floor((windowEnd - Date.now()) / 1000)); | ||
| } | ||
| // ─── Grace-bridge (UTC boundary protection) ───────────────────────────────── | ||
| /** | ||
| * Check if current time is within the grace period around a window boundary. | ||
| * | ||
| * During the grace period, the verifier uses a window-agnostic nullifier | ||
| * to prevent double-spend across the boundary transition. | ||
| * | ||
| * @param {number} nowMs - Current time in milliseconds | ||
| * @param {number} graceSeconds - Grace period in seconds (default: 60) | ||
| * @param {number} windowSec - Window duration in seconds (default: 86400) | ||
| * @returns {boolean} True if within grace period | ||
| */ | ||
| export function isInGracePeriod(nowMs, graceSeconds = 60, windowSec = 86400) { | ||
| const graceMs = graceSeconds * 1000; | ||
| const windowMs = windowSec * 1000; | ||
| const msIntoWindow = nowMs % windowMs; | ||
| // Boundaries are exclusive: [0, graceMs) after and (windowMs - graceMs, windowMs) before | ||
| return msIntoWindow < graceMs || msIntoWindow > (windowMs - graceMs); | ||
| } | ||
| // ─── BRASS derivation functions ───────────────────────────────────────────── | ||
| /** | ||
| * Derive deterministic salt η from service-side context. | ||
| * | ||
| * η is computed exclusively by the verifier from public scope parameters. | ||
| * The client CANNOT choose or influence η (Patent 2/3 core innovation). | ||
| * | ||
| * If a verifier secret is provided, it is mixed into η to prevent: | ||
| * - Precomputation attacks (attacker cannot derive η without the secret) | ||
| * - Cross-verifier nullifier collisions (two verifiers for same origin) | ||
| * | ||
| * @param {string} issuerPK_b64 - Issuer public key (base64url) | ||
| * @param {string} originCanonical - Canonical origin (from canonicalOrigin()) | ||
| * @param {number} epoch - Epoch identifier (e.g., day index) | ||
| * @param {string} policyId - Policy identifier (e.g., "default", "comments") | ||
| * @param {number} window - Window identifier | ||
| * @param {Uint8Array|null} [verifierSecret=null] - Optional per-verifier secret | ||
| * @returns {Uint8Array} 32-byte salt | ||
| */ | ||
| export function deriveEta(issuerPK_b64, originCanonical, epoch, policyId, window, verifierSecret = null) { | ||
| const parts = [ | ||
| 'BRASS_SALT_v1', | ||
| toBytes(issuerPK_b64), | ||
| toBytes(originCanonical), | ||
| toBytes(String(epoch)), | ||
| toBytes(String(policyId)), | ||
| toBytes(String(window)), | ||
| ]; | ||
| if (verifierSecret) { | ||
| parts.push(verifierSecret); | ||
| } | ||
| return H3(...parts); | ||
| } | ||
| /** | ||
| * Derive deterministic nullifier y from token and salt. | ||
| * | ||
| * y is the per-scope, per-window uniqueness key. | ||
| * Same (token, scope, window) → same y. | ||
| * Different window → different y (cross-window unlinkability). | ||
| * | ||
| * @param {string} encZprime_b64 - Canonical encoding of unblinded token Z' (base64url) | ||
| * @param {string} KID - Issuer key identifier | ||
| * @param {string} AADr - Associated data at redemption (includes policy, window config) | ||
| * @param {Uint8Array} eta - Verifier-derived salt | ||
| * @returns {Uint8Array} 32-byte nullifier | ||
| */ | ||
| export function deriveNullifierY(encZprime_b64, KID, AADr, eta) { | ||
| return H2( | ||
| 'BRASS_NULLIFIER_v1', | ||
| toBytes(encZprime_b64), | ||
| toBytes(KID), | ||
| toBytes(AADr), | ||
| eta | ||
| ); | ||
| } | ||
| /** | ||
| * Derive idempotency key for at-most-once request counting. | ||
| * | ||
| * IK = HMAC-SHA256(verifierSecret, len(y) ∥ y ∥ len(c) ∥ c) | ||
| * | ||
| * @param {Uint8Array} kvSecret - Verifier-side secret (32 bytes) | ||
| * @param {Uint8Array} y - Nullifier | ||
| * @param {string} c_b64 - Nonce from verifier (base64url) | ||
| * @returns {string} Idempotency key (base64url) | ||
| */ | ||
| export function deriveIdempotencyKey(kvSecret, y, c_b64) { | ||
| if (!(kvSecret instanceof Uint8Array)) { | ||
| throw new Error('kvSecret must be Uint8Array'); | ||
| } | ||
| if (typeof c_b64 !== 'string') { | ||
| throw new Error('c_b64 must be base64url string'); | ||
| } | ||
| const cBytes = b64urlToBytes(c_b64); | ||
| const message = concat([lengthPrefix(y), lengthPrefix(cBytes)]); | ||
| const mac = hmac(sha256, kvSecret, message); | ||
| return bytesToB64url(mac); | ||
| } | ||
| /** | ||
| * Derive grace-bridge nullifier (window-agnostic). | ||
| * | ||
| * Used during the grace period around window boundaries to prevent | ||
| * double-spend across the transition. Excludes window ID from inputs. | ||
| * | ||
| * @param {string} encZprime_b64 - Token Z' encoding | ||
| * @param {string} KID - Key identifier | ||
| * @param {string} issuerPK_b64 - Issuer public key | ||
| * @param {string} originID - Canonical origin | ||
| * @param {string} policyId - Policy identifier | ||
| * @param {string} suite - Cipher suite (e.g., 'P256_SHA256') | ||
| * @param {string} version - Protocol version (e.g., 'BRASS_v2.0') | ||
| * @param {string} AADr - Associated data at redemption | ||
| * @returns {Uint8Array} 32-byte grace nullifier | ||
| */ | ||
| export function deriveGraceNullifier(encZprime_b64, KID, issuerPK_b64, originID, policyId, suite, version, AADr) { | ||
| return H2( | ||
| 'BRASS_GRACE_v2', | ||
| toBytes(encZprime_b64), | ||
| toBytes(KID), | ||
| toBytes(issuerPK_b64), | ||
| toBytes(originID), | ||
| toBytes(policyId), | ||
| toBytes(suite), | ||
| toBytes(version), | ||
| toBytes(AADr) | ||
| ); | ||
| } | ||
| /** | ||
| * Derive TLS channel binding from exporter bytes or fallback. | ||
| * | ||
| * Binds the verification proof to a specific TLS session when available. | ||
| * Falls back to a domain-separated constant when TLS exporter is not accessible. | ||
| * | ||
| * @param {Uint8Array|null} [tlsExporterBytes=null] - TLS exporter value (RFC 5705/8446) | ||
| * @returns {Uint8Array} 32-byte binding value | ||
| */ | ||
| export function deriveTlsBinding(tlsExporterBytes = null) { | ||
| if (tlsExporterBytes && tlsExporterBytes.length > 0) { | ||
| return H3('tls_exporter', tlsExporterBytes); | ||
| } | ||
| return H3('no_exporter'); | ||
| } | ||
| /** | ||
| * Extract policy ID from associated data string. | ||
| * | ||
| * @param {string} AADr - Associated data at redemption | ||
| * @returns {string} Policy identifier (default: "default") | ||
| */ | ||
| export function parsePolicyId(AADr) { | ||
| const m = /policy=([A-Za-z0-9_-]+)/.exec(AADr); | ||
| return m ? m[1] : 'default'; | ||
| } | ||
| /** | ||
| * Build the counter key tuple for storage lookups. | ||
| * | ||
| * @param {object} params | ||
| * @param {string} params.issuerPk - Issuer public key (base64url) | ||
| * @param {string} params.origin - Canonical origin | ||
| * @param {number} params.epoch - Epoch identifier | ||
| * @param {string} params.policy - Policy identifier | ||
| * @param {number} params.window - Window identifier | ||
| * @param {string} params.y - Nullifier (base64url) | ||
| * @param {string} [params.namespace] - Optional namespace for multi-tenant isolation | ||
| * @returns {string} Composite key for storage | ||
| */ | ||
| export function buildCounterKey({ issuerPk, origin, epoch, policy, window, y, namespace }) { | ||
| const prefix = namespace ? `ns:${namespace}|` : ''; | ||
| return `${prefix}${issuerPk}|${origin}|${epoch}|${policy}|${window}|${y}`; | ||
| } |
-37
| /** | ||
| * @veritasacta/verify | ||
| * | ||
| * Open-source anonymous credential verification using VOPRF (RFC 9497). | ||
| * Issuer-blind, offline, deterministic. | ||
| * | ||
| * @module @veritasacta/verify | ||
| * @license MIT | ||
| */ | ||
| // ─── Core verification ─────────────────────────────────────────────────────── | ||
| export { verify, decodePoint, dleqVerify } from './verifier.js'; | ||
| // ─── Cryptographic primitives ──────────────────────────────────────────────── | ||
| export { | ||
| H2, | ||
| H3, | ||
| toBytes, | ||
| bytesToB64url, | ||
| b64urlToBytes, | ||
| canonicalOrigin, | ||
| currentEpochDays, | ||
| windowId, | ||
| validWindowsWithSkew, | ||
| secondsUntilWindowEnd, | ||
| isInGracePeriod, | ||
| deriveEta, | ||
| deriveNullifierY, | ||
| deriveIdempotencyKey, | ||
| deriveGraceNullifier, | ||
| deriveTlsBinding, | ||
| parsePolicyId, | ||
| buildCounterKey, | ||
| } from './crypto.js'; | ||
| // ─── Storage interface ─────────────────────────────────────────────────────── | ||
| export { BrassCounterStore } from './storage.js'; |
| /** | ||
| * @veritasacta/verify — storage.js | ||
| * | ||
| * Abstract storage interface and counter key builder for BRASS verifiers. | ||
| * Implementations must provide atomic or best-effort spend enforcement. | ||
| * | ||
| * Two reference implementations are provided: | ||
| * - MemoryStore: In-process (testing, single-instance deployments) | ||
| * - KVStore: Cloudflare KV (eventually consistent, free tier) | ||
| * | ||
| * For strongly consistent deployments, use Cloudflare Durable Objects | ||
| * or any backend that provides compare-and-swap (CAS) semantics. | ||
| * | ||
| * @module @veritasacta/verify/storage | ||
| * @license MIT | ||
| */ | ||
| /** | ||
| * Abstract counter store interface. | ||
| * | ||
| * Verifiers call spend() for each redeemed token. Implementations must: | ||
| * 1. Check idempotency key (reject replays) | ||
| * 2. Check counter against threshold (enforce rate limit) | ||
| * 3. Atomically insert nullifier + increment counter | ||
| * 4. Return remaining quota | ||
| * | ||
| * @abstract | ||
| */ | ||
| export class BrassCounterStore { | ||
| /** | ||
| * Attempt to count a token redemption. | ||
| * | ||
| * @param {object} params | ||
| * @param {string} params.counterKey - Composite key from buildCounterKey() | ||
| * @param {string} params.idempotencyKey - IK from deriveIdempotencyKey() | ||
| * @param {number} params.limit - Per-scope threshold (τ) | ||
| * @param {number} params.ttlSeconds - TTL for counter/IK storage (= window duration) | ||
| * @returns {Promise<SpendResult>} | ||
| * | ||
| * @typedef {object} SpendResult | ||
| * @property {boolean} ok - True if accepted, false if denied | ||
| * @property {number} remaining - Remaining quota in this window | ||
| * @property {boolean} [idempotent] - True if this is a replayed request | ||
| * @property {string} [error] - Error code if denied ('rate_limited' | 'replay') | ||
| */ | ||
| async spend(params) { | ||
| throw new Error('spend() must be implemented by subclass'); | ||
| } | ||
| /** | ||
| * Grace guard: check if a grace-bridge nullifier has been seen. | ||
| * | ||
| * @param {object} params | ||
| * @param {string} params.graceKey - Grace nullifier (base64url) | ||
| * @param {number} params.ttlSeconds - Grace cache TTL | ||
| * @returns {Promise<{hit: boolean, response?: object}>} | ||
| */ | ||
| async guardGrace({ graceKey, ttlSeconds }) { | ||
| return { hit: false }; // Safe default: no caching | ||
| } | ||
| /** | ||
| * Cache a response for the grace guard. | ||
| * | ||
| * @param {object} params | ||
| * @param {string} params.graceKey - Grace nullifier (base64url) | ||
| * @param {number} params.ttlSeconds - Grace cache TTL | ||
| * @param {object} params.response - Response to cache | ||
| */ | ||
| async cacheGraceResponse({ graceKey, ttlSeconds, response }) { | ||
| // Default: no-op | ||
| } | ||
| } |
-334
| /** | ||
| * @veritasacta/verify — verifier.js | ||
| * | ||
| * Core verification logic for BRASS anonymous tokens. | ||
| * Pure functions: no routing, no HTTP, no vendor lock-in. | ||
| * | ||
| * This module implements the verifier-side redemption flow from the BRASS | ||
| * protocol (Patent pending). It: | ||
| * | ||
| * 1. Reconstructs scope from transport context (origin, epoch, policy) | ||
| * 2. Derives verifier-chosen salt η from scope (client cannot influence) | ||
| * 3. Verifies issuer DLEQ proof πI (offline, cached) | ||
| * 4. Verifies client DLEQ proof πC (per-redemption, bound to nonce) | ||
| * 5. Computes deterministic nullifier y from token + salt | ||
| * 6. Delegates counting to a pluggable storage backend | ||
| * | ||
| * The issuer is never contacted during verification. | ||
| * | ||
| * @module @veritasacta/verify/verifier | ||
| * @license MIT | ||
| */ | ||
| import { p256 } from '@noble/curves/p256'; | ||
| import { sha256 } from '@noble/hashes/sha256'; | ||
| import { utf8ToBytes } from '@noble/hashes/utils'; | ||
| import { | ||
| H3, | ||
| canonicalOrigin, | ||
| deriveEta, | ||
| deriveNullifierY, | ||
| deriveIdempotencyKey, | ||
| deriveGraceNullifier, | ||
| isInGracePeriod, | ||
| deriveTlsBinding, | ||
| parsePolicyId, | ||
| secondsUntilWindowEnd, | ||
| validWindowsWithSkew, | ||
| windowId, | ||
| bytesToB64url, | ||
| b64urlToBytes, | ||
| buildCounterKey, | ||
| toBytes, | ||
| } from './crypto.js'; | ||
| const n = p256.CURVE.n; | ||
| // ─── Helpers ──────────────────────────────────────────────────────────────── | ||
| function modN(x) { | ||
| let r = x % n; | ||
| return r < 0n ? r + n : r; | ||
| } | ||
| function bytesToBig(b) { | ||
| let hex = ''; | ||
| for (let i = 0; i < b.length; i++) hex += b[i].toString(16).padStart(2, '0'); | ||
| return BigInt('0x' + hex); | ||
| } | ||
| /** Constant-time byte array comparison. */ | ||
| function ctEqual(a, b) { | ||
| if (a.length !== b.length) return false; | ||
| let v = 0; | ||
| for (let i = 0; i < a.length; i++) v |= a[i] ^ b[i]; | ||
| return v === 0; | ||
| } | ||
| /** | ||
| * Decode and validate a P-256 point from base64url. | ||
| * | ||
| * Rejects: off-curve points, non-canonical encodings, point at infinity, | ||
| * invalid prefix bytes, wrong length. | ||
| * | ||
| * @param {string} b64 - Base64url-encoded compressed P-256 point (33 bytes) | ||
| * @returns {import('@noble/curves/p256').ProjPointType} Validated point | ||
| * @throws {Error} If point is invalid | ||
| */ | ||
| export function decodePoint(b64) { | ||
| const bytes = b64urlToBytes(b64); | ||
| let P; | ||
| try { | ||
| P = p256.ProjectivePoint.fromHex(bytes); | ||
| } catch { | ||
| throw new Error('invalid_point_encoding'); | ||
| } | ||
| P.assertValidity(); | ||
| if (P.equals(p256.ProjectivePoint.ZERO)) { | ||
| throw new Error('invalid_point_infinity'); | ||
| } | ||
| return P; | ||
| } | ||
| // ─── DLEQ Verification ───────────────────────────────────────────────────── | ||
| /** | ||
| * Verify a Schnorr-style DLEQ proof. | ||
| * | ||
| * Proves: log_{g1}(h1) = log_{g2}(h2) | ||
| * With Fiat-Shamir binding to arbitrary context via `bind`. | ||
| * | ||
| * Used for both πI (issuer proof) and πC (client proof). | ||
| * | ||
| * @param {object} params | ||
| * @param {string} params.label - Domain separation label | ||
| * @param {ProjPoint} params.g1 - First generator | ||
| * @param {ProjPoint} params.h1 - First public value | ||
| * @param {ProjPoint} params.g2 - Second generator | ||
| * @param {ProjPoint} params.h2 - Second public value | ||
| * @param {ProjPoint} params.A1 - First commitment (r·g1) | ||
| * @param {ProjPoint} params.A2 - Second commitment (r·g2) | ||
| * @param {bigint} params.c - Claimed challenge scalar | ||
| * @param {bigint} params.r - Response scalar | ||
| * @param {Uint8Array} params.bind - Context binding bytes (nonce, digest, salt) | ||
| * @returns {boolean} True if proof is valid | ||
| */ | ||
| export function dleqVerify({ label, g1, h1, g2, h2, A1, A2, c, r, bind }) { | ||
| const challenge = H3( | ||
| `BRASS:${label}:`, | ||
| g1.toRawBytes(true), | ||
| h1.toRawBytes(true), | ||
| g2.toRawBytes(true), | ||
| h2.toRawBytes(true), | ||
| A1.toRawBytes(true), | ||
| A2.toRawBytes(true), | ||
| bind | ||
| ); | ||
| const chal = modN(bytesToBig(challenge)); | ||
| return chal === c; | ||
| } | ||
| // ─── Core Verification ────────────────────────────────────────────────────── | ||
| /** | ||
| * @typedef {object} VerifyConfig | ||
| * @property {string} issuerPubKey - Issuer public key Y = k·g (base64url, compressed P-256) | ||
| * @property {string} keyId - Issuer key identifier (KID) | ||
| * @property {Uint8Array} kvSecret - 32-byte secret for idempotency key derivation | ||
| * @property {Uint8Array} [verifierSecret] - Optional per-verifier secret for salt derivation | ||
| * @property {number} [rateLimit=100] - Per-scope threshold (τ) | ||
| * @property {number} [windowSec=86400] - Window duration in seconds | ||
| * @property {number} [graceSeconds=60] - Grace period for boundary protection | ||
| * @property {string} [protocolVersion='BRASS_v2.0'] - Protocol version | ||
| * @property {string} [cipherSuite='P256_SHA256'] - Cipher suite | ||
| */ | ||
| /** | ||
| * @typedef {object} RedemptionMessage | ||
| * @property {string} M - Blinded element (base64url, compressed P-256) | ||
| * @property {string} Z - Issuer evaluation Z = k·M (base64url) | ||
| * @property {string} Zprime - Unblinded token Z' = k·P (base64url) | ||
| * @property {string} [P] - Scope point P = H1(ctx(S)) (optional, verifier can reconstruct) | ||
| * @property {object} piI - Issuer DLEQ proof {A1, A2, c, r} (all base64url except c,r are hex bigints) | ||
| * @property {object} piC - Client DLEQ proof {A1, A2, c, r} bound to (nonce, digest, η) | ||
| * @property {string} c_nonce - Verifier-issued nonce (base64url) | ||
| * @property {string} [d] - HTTP context digest (base64url, optional) | ||
| * @property {string} AADr - Associated data at redemption | ||
| * @property {string} [eta] - Salt η (base64url, optional — verifier recomputes, client may echo) | ||
| */ | ||
| /** | ||
| * @typedef {object} RequestContext | ||
| * @property {string} origin - Request origin (from HTTP Origin header or verified fallback) | ||
| * @property {string} [httpMethod] - HTTP method (for digest d) | ||
| * @property {string} [normalizedPath] - Normalized request path | ||
| * @property {Uint8Array} [bodyDigest] - SHA-256 of request body | ||
| * @property {Uint8Array} [tlsExporterBytes] - TLS exporter value | ||
| */ | ||
| /** | ||
| * Verify a BRASS token redemption. | ||
| * | ||
| * This is the core function. It implements the complete verifier-side | ||
| * redemption flow without contacting the issuer. | ||
| * | ||
| * @param {RedemptionMessage} msg - The redemption message from the client | ||
| * @param {RequestContext} ctx - Request context derived from transport | ||
| * @param {VerifyConfig} config - Verifier configuration | ||
| * @param {import('./storage.js').BrassCounterStore} store - Counter store | ||
| * @returns {Promise<{ok: boolean, remaining?: number, error?: string}>} | ||
| */ | ||
| export async function verify(msg, ctx, config, store) { | ||
| const { | ||
| issuerPubKey, | ||
| keyId, | ||
| kvSecret, | ||
| verifierSecret = null, | ||
| rateLimit = 100, | ||
| windowSec = 86400, | ||
| graceSeconds = 60, | ||
| protocolVersion = 'BRASS_v2.0', | ||
| cipherSuite = 'P256_SHA256', | ||
| } = config; | ||
| const nowMs = Date.now(); | ||
| // ── 1. Derive scope from transport context ────────────────────────────── | ||
| let originCanonical; | ||
| try { | ||
| originCanonical = canonicalOrigin(ctx.origin); | ||
| } catch { | ||
| return { ok: false, error: 'invalid_origin' }; | ||
| } | ||
| const currentWindow = windowId(nowMs, windowSec); | ||
| const validWindows = validWindowsWithSkew(nowMs, windowSec); | ||
| const epoch = Math.floor(nowMs / 86400000); // Day-level epoch | ||
| const policyId = parsePolicyId(msg.AADr || ''); | ||
| // ── 2. Derive verifier-chosen salt η ──────────────────────────────────── | ||
| const eta = deriveEta( | ||
| issuerPubKey, originCanonical, epoch, policyId, currentWindow, | ||
| verifierSecret | ||
| ); | ||
| // ── 3. Decode and validate all points ─────────────────────────────────── | ||
| let M, Z, Zprime, Y; | ||
| try { | ||
| M = decodePoint(msg.M); | ||
| Z = decodePoint(msg.Z); | ||
| Zprime = decodePoint(msg.Zprime); | ||
| Y = decodePoint(issuerPubKey); | ||
| } catch (e) { | ||
| return { ok: false, error: e.message || 'invalid_point' }; | ||
| } | ||
| // ── 4. Verify issuer proof πI: log_g(Y) = log_M(Z) ───────────────────── | ||
| const G = p256.ProjectivePoint.BASE; | ||
| try { | ||
| const piI = msg.piI; | ||
| const valid = dleqVerify({ | ||
| label: 'OPRF_METERING_DLEQ_v1', | ||
| g1: G, | ||
| h1: Y, | ||
| g2: M, | ||
| h2: Z, | ||
| A1: decodePoint(piI.A1), | ||
| A2: decodePoint(piI.A2), | ||
| c: BigInt('0x' + piI.c), | ||
| r: BigInt('0x' + piI.r), | ||
| bind: toBytes(msg.AADr || ''), | ||
| }); | ||
| if (!valid) return { ok: false, error: 'invalid_piI' }; | ||
| } catch { | ||
| return { ok: false, error: 'invalid_piI' }; | ||
| } | ||
| // ── 5. Verify client proof πC: log_P(M) = log_{Z'}(Z), bound to (c, d, η) ─ | ||
| // πC must be recomputed per-redemption — prevents token theft/replay | ||
| const P = msg.P ? decodePoint(msg.P) : null; // P from client or reconstructed | ||
| if (P) { | ||
| try { | ||
| const piC = msg.piC; | ||
| const tlsBinding = deriveTlsBinding(ctx.tlsExporterBytes || null); | ||
| const bindContext = H3( | ||
| toBytes(msg.c_nonce || ''), | ||
| toBytes(msg.d || ''), | ||
| eta, | ||
| tlsBinding | ||
| ); | ||
| const valid = dleqVerify({ | ||
| label: 'OPRF_METERING_DLEQ_v1', | ||
| g1: P, | ||
| h1: M, | ||
| g2: Zprime, | ||
| h2: Z, | ||
| A1: decodePoint(piC.A1), | ||
| A2: decodePoint(piC.A2), | ||
| c: BigInt('0x' + piC.c), | ||
| r: BigInt('0x' + piC.r), | ||
| bind: bindContext, | ||
| }); | ||
| if (!valid) return { ok: false, error: 'invalid_piC' }; | ||
| } catch { | ||
| return { ok: false, error: 'invalid_piC' }; | ||
| } | ||
| } | ||
| // ── 6. Compute deterministic nullifier y ──────────────────────────────── | ||
| const y = deriveNullifierY(msg.Zprime, keyId, msg.AADr || '', eta); | ||
| const y_b64 = bytesToB64url(y); | ||
| // ── 7. Grace-bridge check (if within grace period) ────────────────────── | ||
| const inGrace = isInGracePeriod(nowMs, graceSeconds, windowSec); | ||
| if (inGrace) { | ||
| const graceY = deriveGraceNullifier( | ||
| msg.Zprime, keyId, issuerPubKey, originCanonical, | ||
| policyId, cipherSuite, protocolVersion, msg.AADr || '' | ||
| ); | ||
| const graceKey = bytesToB64url(graceY); | ||
| const graceResult = await store.guardGrace({ graceKey, ttlSeconds: graceSeconds * 2 }); | ||
| if (graceResult.hit) { | ||
| return graceResult.response; | ||
| } | ||
| // Process normally, then cache the result | ||
| const result = await _countAndEnforce(y_b64, msg.c_nonce, { | ||
| issuerPk: issuerPubKey, origin: originCanonical, epoch, policy: policyId, | ||
| window: currentWindow, | ||
| }, config, store); | ||
| await store.cacheGraceResponse({ | ||
| graceKey, ttlSeconds: graceSeconds * 2, response: result, | ||
| }); | ||
| return result; | ||
| } | ||
| // ── 8. Normal path: count and enforce ─────────────────────────────────── | ||
| return _countAndEnforce(y_b64, msg.c_nonce, { | ||
| issuerPk: issuerPubKey, origin: originCanonical, epoch, policy: policyId, | ||
| window: currentWindow, | ||
| }, config, store); | ||
| } | ||
| /** | ||
| * Internal: count redemption and enforce threshold. | ||
| */ | ||
| async function _countAndEnforce(y_b64, c_nonce, scope, config, store) { | ||
| const { kvSecret, rateLimit = 100, windowSec = 86400 } = config; | ||
| const IK = deriveIdempotencyKey(kvSecret, b64urlToBytes(y_b64), c_nonce || ''); | ||
| const counterKey = buildCounterKey({ ...scope, y: y_b64 }); | ||
| const ttlSeconds = secondsUntilWindowEnd(scope.window, windowSec); | ||
| return store.spend({ | ||
| counterKey, | ||
| idempotencyKey: IK, | ||
| limit: rateLimit, | ||
| ttlSeconds, | ||
| }); | ||
| } | ||
| // decodePoint and dleqVerify are exported inline via `export function` above. |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
589766
38.71%73
25.86%12919
32.37%359
11.15%3
200%+ Added
+ Added