diff --git a/LICENSE b/LICENSE index 6b79e20..d22aaf4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,202 +1,21 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2026 WAVE, Inc. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - +MIT License + +Copyright (c) 2026 WAVE Online, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index ba29503..5c19797 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-# wave Dispatch — edge worker +# WAVE Dispatch — edge worker **Local-first AI routing.** Send every request to the cheapest *capable* model — your local models first ($0, your infra), escalating to a frontier (Claude / GPT / Gemini / …) only when confidence is low. @@ -26,7 +26,7 @@ Most agent and LLM workloads send *everything* to a frontier model — including local model handles perfectly. You pay frontier prices for work that didn't need it, and your prompts leave your infra on every call. -wave Dispatch puts a tiny classifier at the edge. It embeds each request (Workers AI, `bge-base-en`), +WAVE Dispatch puts a tiny classifier at the edge. It embeds each request (Workers AI, `bge-base-en`), runs a matmul over bundled weights, and returns a **routing decision**: which tier should handle this, with a confidence and margin. Your runtime does the rest — local models on your hardware, frontier only when the classifier isn't confident enough. diff --git a/sdk/README.md b/sdk/README.md index a1fd2b7..defe13f 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -1,4 +1,4 @@ -# wave Dispatch SDKs +# WAVE Dispatch SDKs Thin clients for the edge API (`POST /` → `{route, probability, margin, forward}`; `execute:true` for edge-local answers; `vector:[768]` for matmul-only; bearer license or x402). Your keys + infra stay yours. diff --git a/sdk/js/README.md b/sdk/js/README.md index fc20400..180e679 100644 --- a/sdk/js/README.md +++ b/sdk/js/README.md @@ -1,6 +1,6 @@ # @wave-av/dispatch -JS/TS client for [wave Dispatch](https://dispatch.wave.online) — route each request to the cheapest +JS/TS client for [WAVE Dispatch](https://dispatch.wave.online) — route each request to the cheapest capable model (local-first; escalate to your frontier model only when needed). BYO keys + infra. ```js diff --git a/sdk/js/attest.test.js b/sdk/js/attest.test.js new file mode 100644 index 0000000..48d6af6 --- /dev/null +++ b/sdk/js/attest.test.js @@ -0,0 +1,56 @@ +// Zero-dep tests (node:test) for the SDK's offline attestation verifier. The decisive test is +// CROSS-LANGUAGE: an attestation signed by the Python signer (attest.py) must verify here byte-for-byte — +// that's what makes "sign in the runtime, verify in any SDK, trust no one" real. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { verifyAttestation, attestationTruncated, canonicalAttestation } from "./index.js"; + +// The exact vector emitted by attest.py (also pinned in edge-router/context-attest.test.ts). +const BASE = { + v: "wave.context-attestation/v0", ts: 1700000000, model: "qwen3:30b", source: "café.md", + chunk: 2, chunk_sha: "deadbeef", chunk_chars: 2980, num_ctx: 8192, prompt_sha: "cafe1234", + kept: 3, dropped_hallucinated: 1, +}; +const PY_CANONICAL = + '{"chunk":2,"chunk_chars":2980,"chunk_sha":"deadbeef","dropped_hallucinated":1,"kept":3,' + + '"model":"qwen3:30b","num_ctx":8192,"prompt_sha":"cafe1234","source":"caf\\u00e9.md",' + + '"ts":1700000000,"v":"wave.context-attestation/v0"}'; +const PY_PUBKEY = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8"; +const PY_SIG = + "68017540b4c3b060e51faab64c28f4e1ea6994d3781fec14878e73052046d78d" + + "05757a8287af644b245b10274ba3b60329ed216472839d7573debf4256e9730b"; + +test("canonical string matches the Python signer byte-for-byte (café -> caf\\u00e9)", () => { + assert.equal(canonicalAttestation(BASE), PY_CANONICAL); +}); + +test("verifies a Python Ed25519 signature with NO secret (trustless, offline)", async () => { + assert.equal(await verifyAttestation({ ...BASE, alg: "ed25519", sig: PY_SIG, pubkey: PY_PUBKEY }), true); +}); + +test("rejects a tampered field against the Python signature", async () => { + assert.equal(await verifyAttestation({ ...BASE, kept: 999, alg: "ed25519", sig: PY_SIG, pubkey: PY_PUBKEY }), false); +}); + +test("HMAC: verifies with the key, fails with the wrong one, null without it", async () => { + const subtle = globalThis.crypto.subtle; + const enc = new TextEncoder(); + const key = await subtle.importKey("raw", enc.encode("k"), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + const sig = [...new Uint8Array(await subtle.sign("HMAC", key, enc.encode(canonicalAttestation(BASE))))] + .map((x) => x.toString(16).padStart(2, "0")).join(""); + const att = { ...BASE, alg: "hmac-sha256", sig }; + assert.equal(await verifyAttestation(att, { hmacKey: "k" }), true); + assert.equal(await verifyAttestation(att, { hmacKey: "wrong" }), false); + assert.equal(await verifyAttestation(att), null); // HMAC without our key -> uncheckable +}); + +test("unsigned / no-alg -> null (not false)", async () => { + assert.equal(await verifyAttestation({ ...BASE }), null); + assert.equal(await verifyAttestation({ ...BASE, alg: "none", sig: null }), null); +}); + +test("truncation is derivable from two hashes", () => { + assert.equal(attestationTruncated({ chunk_sha: "a", prompt_sha: "b" }), true); + assert.equal(attestationTruncated({ chunk_sha: "a", prompt_sha: "a" }), false); + assert.equal(attestationTruncated({ chunk_sha: "a" }), null); +}); diff --git a/sdk/js/index.d.ts b/sdk/js/index.d.ts index 8e5d850..c654745 100644 --- a/sdk/js/index.d.ts +++ b/sdk/js/index.d.ts @@ -1,4 +1,4 @@ -// Type declarations for @wave-av/dispatch — the JS/TS client for wave Dispatch. +// Type declarations for @wave-av/dispatch — the JS/TS client for WAVE Dispatch. // Hand-written to match index.js exactly (the JS is the source of truth). A WAVE product. /** Routing decision returned by `route` / `execute` / `routeVector` (POST `/`). */ @@ -116,7 +116,7 @@ export interface WalletHookConfig { fetchImpl?: FetchImpl; } -/** Client for the wave Dispatch edge API. */ +/** Client for the WAVE Dispatch edge API. */ export declare class Dispatch { license?: string; endpoint: string; @@ -154,4 +154,74 @@ export declare class Dispatch { ): Promise; } +/** A WAVE context-attestation — signed proof of what a model saw. See docs/attest-hotpath.md. */ +export interface ContextAttestation { + v?: string; ts?: number; model?: string; source?: string; chunk?: number; + chunk_sha?: string; chunk_chars?: number; num_ctx?: number; prompt_sha?: string; + kept?: number; dropped_hallucinated?: number; + alg?: "none" | "hmac-sha256" | "ed25519"; sig?: string | null; pubkey?: string; key_id?: string; +} + +/** Canonical signing string for an attestation — byte-for-byte identical to the edge/Python signer. */ +export function canonicalAttestation(att: ContextAttestation): string; + +/** True if the model saw less than the full prompt (chunk_sha !== prompt_sha); null if undeterminable. */ +export function attestationTruncated(att: ContextAttestation): boolean | null; + +/** + * Verify a context-attestation offline & trustless. Returns `true`/`false` when signed & checkable, or + * `null` when unsigned (or an HMAC attestation without its key). Ed25519 needs no secret (self-describing). + */ +export function verifyAttestation(att: ContextAttestation, opts?: { hmacKey?: string; registry?: TrustedRegistry }): Promise; + +/** A WAVE Payment-Receipt — dispatch's signed proof it verified a settlement (the money half of the two receipts). */ +export interface PaymentReceipt { + v?: string; ts?: number; protocol?: string; mode?: string; resource?: string; + network?: string; asset?: string | null; amount_atomic?: string; pay_to?: string | null; + tx_hash?: string | null; verified?: boolean; + alg?: "none" | "hmac-sha256" | "ed25519"; sig?: string | null; pubkey?: string; key_id?: string; +} + +/** Canonical signing string for a Payment-Receipt — byte-for-byte identical to the edge signer. */ +export function canonicalPaymentReceipt(r: PaymentReceipt): string; + +/** + * Verify a Payment-Receipt offline & trustless. Returns `true`/`false` when signed & checkable, or `null` + * when unsigned (or an HMAC receipt without its key). Ed25519 needs no secret (self-describing). + */ +export function verifyPaymentReceipt(r: PaymentReceipt, opts?: { hmacKey?: string; registry?: TrustedRegistry }): Promise; + +// ── trusted-key registry (E4.followup): trust's SECOND axis — "is the signer WAVE's key?" ───────────── + +/** An immutable set of trusted signer pubkeys, built by {@link makeRegistry}. */ +export interface TrustedRegistry { + /** True if `pubkeyHex` (case-insensitive) is a trusted key. */ + has(pubkeyHex: string): boolean; + /** Number of distinct trusted keys (0 => an empty registry, which decides nothing). */ + size: number; +} + +/** A `/.well-known/wave-keys.json` payload (or any `{keys:[...]}`) accepted by {@link makeRegistry}. */ +export interface WaveKeysPayload { + keys: Array; + [k: string]: unknown; +} + +/** + * Build a {@link TrustedRegistry} from `{pubkey, key_id?}` entries (or bare pubkey-hex strings), or a + * `/.well-known/wave-keys.json` payload. Malformed (non-hex / odd-length) entries are dropped. + */ +export function makeRegistry( + entries?: WaveKeysPayload | Array | null +): TrustedRegistry; + +/** + * Is the signer's key one WAVE published? `true` = ed25519 key in `registry` with an honest key_id; `false` + * = a valid-shaped key that is NOT trusted (or a key_id that lies about its pubkey); `null` = cannot decide + * (no/empty registry, unsigned, "none", or HMAC). Does NOT verify the signature — that's verify*(). + */ +export function trustedSigner( + r: PaymentReceipt | ContextAttestation, registry?: TrustedRegistry | null +): Promise; + export default Dispatch; diff --git a/sdk/js/index.js b/sdk/js/index.js index e949f18..0964215 100644 --- a/sdk/js/index.js +++ b/sdk/js/index.js @@ -1,4 +1,4 @@ -// wave Dispatch — JS/TS client for the edge API. Route each request to the cheapest capable model +// WAVE Dispatch — JS/TS client for the edge API. Route each request to the cheapest capable model // (local-first; escalate to your frontier model only when needed). Your keys + infra stay yours. const DEFAULT_ENDPOINT = "https://dispatch.wave.online"; const DEFAULT_AGENTS_ENDPOINT = "https://dispatch-agents.wave.online"; // stateful sidecar: savings + subscriptions @@ -211,4 +211,75 @@ async function _signCdpJwt(creds, accept) { // canonical, camelCase aliases coerced via _normCreds. Dispatch.signCdpJwt = (creds, accept) => _signCdpJwt(_normCreds(creds), accept); +// ── Context-attestation verification (offline + trustless) ───────────────────────────────────────── +// Verify a WAVE context-attestation WITHOUT trusting WAVE: an Ed25519 attestation embeds its own public +// key, so this confirms "what the model saw" (and whether the prompt was truncated) using only WebCrypto — +// no network, no secret, no dependency. HMAC attestations need the shared secret (pass {hmacKey}). The +// canonical form is byte-identical to the edge signer (edge-router/context-attest.ts) and the Python signer, +// pinned by the cross-language vector in attest.test.js. See docs/attest-hotpath.md. +const ATTEST_FIELDS = ["v", "ts", "model", "source", "chunk", "chunk_sha", "chunk_chars", "num_ctx", "prompt_sha", "kept", "dropped_hallucinated"]; + +function _asciiEscape(s) { + // Python's json.dumps(ensure_ascii=True) escapes every non-ASCII char as \uXXXX; JSON.stringify does not. + return s.replace(/[€-￿]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0")); +} + +/** Canonical signing string for an attestation — byte-for-byte identical to the edge/Python signer. */ +export function canonicalAttestation(att) { + const sorted = {}; + for (const k of [...ATTEST_FIELDS].sort()) sorted[k] = att?.[k] ?? null; // missing -> null, like Python None + return _asciiEscape(JSON.stringify(sorted)); +} + +/** True if the model saw less than the full prompt (chunk_sha !== prompt_sha); null if undeterminable. */ +export function attestationTruncated(att) { + return typeof att?.prompt_sha === "string" && typeof att?.chunk_sha === "string" ? att.chunk_sha !== att.prompt_sha : null; +} + +function _fromHex(h) { const a = new Uint8Array(h.length / 2); for (let i = 0; i < a.length; i++) a[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); return a; } +function _toHex(buf) { let o = ""; for (const x of new Uint8Array(buf)) o += x.toString(16).padStart(2, "0"); return o; } +// constant-time string compare — no length/early-exit leak (mirrors edge crypto-util.timingSafeEqual). +function _timingSafeEqual(a, b) { const n = Math.max(a.length, b.length); let m = a.length ^ b.length; for (let i = 0; i < n; i++) m |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0); return m === 0; } + +/** + * Verify a context-attestation. Returns `true`/`false` when it is signed and checkable, and `null` when it + * is unsigned or is an HMAC attestation given without its key. Ed25519 is self-describing (no key needed) — + * fully trustless + offline. Throws only if WebCrypto is unavailable (need Node >=18 or a modern browser). + */ +async function _attSigValid(att, hmacKey) { // crypto half: is the SIGNATURE valid? (true/false/null) + if (!att || !att.alg || att.alg === "none" || !att.sig) return null; + const subtle = globalThis.crypto && globalThis.crypto.subtle; + if (!subtle) throw new Error("WebCrypto unavailable — verifyAttestation needs Node >=18 or a modern browser"); + const msg = new TextEncoder().encode(canonicalAttestation(att)); + if (att.alg === "ed25519") { + if (!att.pubkey) return null; + try { + const pub = await subtle.importKey("raw", _fromHex(att.pubkey), "Ed25519", false, ["verify"]); + return await subtle.verify("Ed25519", pub, _fromHex(att.sig), msg); // self-describing: anyone verifies + } catch { return false; } + } + if (att.alg === "hmac-sha256") { + if (!hmacKey) return null; // we-verify only with the shared key + const key = await subtle.importKey("raw", new TextEncoder().encode(hmacKey), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + return _timingSafeEqual(_toHex(await subtle.sign("HMAC", key, msg)), att.sig); + } + return null; +} + +/** + * Verify a context-attestation (signature half: {@link _attSigValid}). Pass `{registry}` to ALSO require the + * signer be a WAVE-published key — a valid signature from an untrusted key then returns `false`. Without + * `{registry}`, behaviour is byte-identical to before. + */ +export async function verifyAttestation(att, { hmacKey, registry } = {}) { + const v = await _attSigValid(att, hmacKey); + if (registry && v === true && (await trustedSigner(att, registry)) === false) return false; + return v; +} + +// ── Payment-Receipt + trusted-key registry: both live in ./verify.js (self-contained, zero-dep) so this +// entrypoint stays under the 300-line gate; re-exported so the public API is unchanged. ──────────────── +import { trustedSigner } from "./verify.js"; // local use by the {registry} fold above (imports are hoisted) +export { canonicalPaymentReceipt, verifyPaymentReceipt, makeRegistry, trustedSigner } from "./verify.js"; + export default Dispatch; diff --git a/sdk/js/package.json b/sdk/js/package.json index f09f0ef..a49d1de 100644 --- a/sdk/js/package.json +++ b/sdk/js/package.json @@ -1,15 +1,16 @@ { "name": "@wave-av/dispatch", "version": "0.7.0", - "description": "wave Dispatch — route each request to the cheapest capable model (local-first; escalate to your frontier model only when needed). BYO keys + infra; we return the routing decision.", + "description": "WAVE Dispatch — route each request to the cheapest capable model (local-first; escalate to your frontier model only when needed). BYO keys + infra; we return the routing decision.", "type": "module", "main": "index.js", "types": "index.d.ts", "exports": { ".": { "types": "./index.d.ts", "default": "./index.js" } }, "bin": { "dispatch": "bin/dispatch.js" }, - "files": ["index.js", "index.d.ts", "bin/", "README.md"], + "files": ["index.js", "verify.js", "index.d.ts", "bin/", "README.md"], "scripts": { - "typecheck": "tsc -p tsconfig.json" + "typecheck": "tsc -p tsconfig.json", + "test": "node --test" }, "keywords": ["llm", "routing", "local-first", "cost", "edge", "wave", "dispatch", "x402", "agents"], "author": "WAVE Online, LLC", diff --git a/sdk/js/receipt.test.js b/sdk/js/receipt.test.js new file mode 100644 index 0000000..8041345 --- /dev/null +++ b/sdk/js/receipt.test.js @@ -0,0 +1,52 @@ +// Zero-dep tests (node:test) for the SDK's offline Payment-Receipt verifier — the MONEY half of "the two +// receipts". The decisive test is CROSS-IMPLEMENTATION: EXPECTED_CANONICAL is the SAME constant pinned in +// edge-router/payment-receipt.test.ts, so a green here proves the SDK and the edge signer produce byte-for- +// byte identical canonical strings — "sign on the edge, verify in any SDK, trust no one" for payments. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { canonicalPaymentReceipt, verifyPaymentReceipt } from "./index.js"; + +// Pinned identically in edge-router/payment-receipt.test.ts (the byte-parity vector). +const FIXED = { + v: "wave.payment-receipt/v0", ts: 1700000000, protocol: "x402", mode: "wave-x402", + resource: "/extract", network: "base", asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + amount_atomic: "1000", pay_to: "0x0000000000000000000000000000000000000001", + tx_hash: "0xdeadbeef", verified: true, +}; +const EXPECTED_CANONICAL = + '{"amount_atomic":"1000","asset":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",' + + '"mode":"wave-x402","network":"base","pay_to":"0x0000000000000000000000000000000000000001",' + + '"protocol":"x402","resource":"/extract","ts":1700000000,"tx_hash":"0xdeadbeef",' + + '"v":"wave.payment-receipt/v0","verified":true}'; + +const _toHex = (buf) => [...new Uint8Array(buf)].map((x) => x.toString(16).padStart(2, "0")).join(""); + +test("canonical receipt string is byte-identical to the edge signer (cross-impl parity)", () => { + assert.equal(canonicalPaymentReceipt(FIXED), EXPECTED_CANONICAL); +}); + +test("Ed25519: signs, self-verifies with NO secret (trustless, offline); tamper fails", async () => { + const subtle = globalThis.crypto.subtle, enc = new TextEncoder(); + const kp = await subtle.generateKey("Ed25519", true, ["sign", "verify"]); + const pubkey = _toHex(await subtle.exportKey("raw", kp.publicKey)); + const sig = _toHex(await subtle.sign("Ed25519", kp.privateKey, enc.encode(canonicalPaymentReceipt(FIXED)))); + const signed = { ...FIXED, alg: "ed25519", sig, pubkey }; + assert.equal(await verifyPaymentReceipt(signed), true); + assert.equal(await verifyPaymentReceipt({ ...signed, amount_atomic: "999999" }), false); // tampered money amount + assert.equal(await verifyPaymentReceipt({ ...signed, verified: false }), false); // tampered settlement flag +}); + +test("HMAC: verifies with the key, fails with the wrong one, null without it", async () => { + const subtle = globalThis.crypto.subtle, enc = new TextEncoder(); + const key = await subtle.importKey("raw", enc.encode("k"), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + const sig = _toHex(await subtle.sign("HMAC", key, enc.encode(canonicalPaymentReceipt(FIXED)))); + const r = { ...FIXED, alg: "hmac-sha256", sig }; + assert.equal(await verifyPaymentReceipt(r, { hmacKey: "k" }), true); + assert.equal(await verifyPaymentReceipt(r, { hmacKey: "wrong" }), false); + assert.equal(await verifyPaymentReceipt(r), null); // HMAC without our key -> uncheckable +}); + +test("unsigned / no-alg -> null (not false)", async () => { + assert.equal(await verifyPaymentReceipt({ ...FIXED }), null); + assert.equal(await verifyPaymentReceipt({ ...FIXED, alg: "none", sig: null }), null); +}); diff --git a/sdk/js/trusted.test.js b/sdk/js/trusted.test.js new file mode 100644 index 0000000..2c2ad5b --- /dev/null +++ b/sdk/js/trusted.test.js @@ -0,0 +1,94 @@ +// trusted.test.js — the cross-implementation pin for the TRUSTED-KEY REGISTRY: trust's SECOND axis. +// verify*() proves the SIGNATURE; trustedSigner() proves the KEY is one WAVE published (registry membership +// + key_id honesty); passing {registry} to verify*() FOLDS the two into one verdict ("valid AND ours"). +// The FIXED vectors below are byte-identical to tests/test_sdk_python_verify.py and the Go/Ruby/Rust ports + +// the edge — a green here proves the trust logic is the SAME in every runtime. NOTE key_id is the edge's +// real derivation: sha256Hex(fromHex(pubkey)).slice(0,16) — the FIRST 16 hex chars, not the full digest. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { makeRegistry, trustedSigner, verifyPaymentReceipt, verifyAttestation } from "./index.js"; + +// --- FIXED Payment-Receipt vector (identical to receipt.test.js / test_sdk_python_verify.py) --- +const PUBKEY = "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c"; +const KEY_ID = "34750f98bd59fcfc"; // = sha256Hex(fromHex(PUBKEY)).slice(0,16) — the edge's exact id +const OTHER = "00".repeat(32); // a structurally-valid 32-byte pubkey that is NOT WAVE's +const FIXED_RECEIPT = { + v: "wave.payment-receipt/v0", ts: 1700000000, protocol: "x402", mode: "wave-x402", + resource: "/extract", network: "base", asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + amount_atomic: "1000", pay_to: "0x0000000000000000000000000000000000000001", + tx_hash: "0xdeadbeef", verified: true, +}; +const RECEIPT_SIG = "e333772435ba2b16c9b52188a489de18f0dbba870a84b6dcc6fe63ef3dffae5a" + + "b9e65daa85b3306d4d3df879bc47c6282f87c661e634390a9a5d93e7c502220a"; +const SIGNED = { ...FIXED_RECEIPT, alg: "ed25519", sig: RECEIPT_SIG, pubkey: PUBKEY, key_id: KEY_ID }; + +// --- FIXED context-attestation vector (different signer key — the Python-signer key) --- +const ATT_PUBKEY = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8"; +const ATT_KEY_ID = "56475aa75463474c"; +const BASE_ATT = { + v: "wave.context-attestation/v0", ts: 1700000000, model: "qwen3:30b", source: "café.md", + chunk: 2, chunk_sha: "deadbeef", chunk_chars: 2980, num_ctx: 8192, prompt_sha: "cafe1234", + kept: 3, dropped_hallucinated: 1, +}; +const ATT_SIG = "68017540b4c3b060e51faab64c28f4e1ea6994d3781fec14878e73052046d78d" + + "05757a8287af644b245b10274ba3b60329ed216472839d7573debf4256e9730b"; +const SIGNED_ATT = { ...BASE_ATT, alg: "ed25519", sig: ATT_SIG, pubkey: ATT_PUBKEY, key_id: ATT_KEY_ID }; + +test("makeRegistry: pubkey membership, case-insensitive, drops malformed, reads /.well-known payload", () => { + const reg = makeRegistry([{ pubkey: PUBKEY, key_id: KEY_ID }]); + assert.equal(reg.size, 1); + assert.equal(reg.has(PUBKEY), true); + assert.equal(reg.has(PUBKEY.toUpperCase()), true); // membership is case-insensitive + assert.equal(reg.has(OTHER), false); + // accepts a {keys:[...]} well-known payload AND bare-string entries; silently drops non-hex / odd-length + const reg2 = makeRegistry({ keys: [PUBKEY, "nothex!!", "abc"] }); + assert.equal(reg2.size, 1); + assert.equal(makeRegistry(null).size, 0); // no entries -> empty (an empty registry decides nothing) +}); + +test("trustedSigner: tri-state — true=ours, false=valid-but-untrusted, null=cannot-decide", async () => { + const reg = makeRegistry([PUBKEY]); + assert.equal(await trustedSigner(SIGNED, reg), true); // WAVE's published key + assert.equal(await trustedSigner(SIGNED, makeRegistry([OTHER])), false); // valid shape, NOT trusted + assert.equal(await trustedSigner(SIGNED, null), null); // no registry -> cannot decide + assert.equal(await trustedSigner(SIGNED, makeRegistry([])), null); // empty registry -> cannot decide + assert.equal(await trustedSigner({ ...SIGNED, alg: "hmac-sha256" }, reg), null); // a pubkey-registry can't vouch for a shared secret + assert.equal(await trustedSigner({ ...FIXED_RECEIPT, alg: "none" }, reg), null); // unsigned +}); + +test("trustedSigner: a key_id that LIES about its pubkey -> false (even when the pubkey IS trusted)", async () => { + const reg = makeRegistry([PUBKEY]); + assert.equal(await trustedSigner({ ...SIGNED, key_id: "deadbeefdeadbeef" }, reg), false); // recompute catches the lie + assert.equal(await trustedSigner({ ...SIGNED, key_id: undefined }, reg), true); // absent key_id is fine (membership is by pubkey) +}); + +test("FOLD: verifyPaymentReceipt({registry}) = signature-valid AND key-trusted; backward-compatible without it", async () => { + assert.equal(await verifyPaymentReceipt(SIGNED), true); // no registry -> identical to today + assert.equal(await verifyPaymentReceipt(SIGNED, { registry: makeRegistry([PUBKEY]) }), true); // valid + ours + assert.equal(await verifyPaymentReceipt(SIGNED, { registry: makeRegistry([OTHER]) }), false); // valid sig, UNtrusted key -> reject + assert.equal( // a registry NEVER upgrades a bad sig + await verifyPaymentReceipt({ ...SIGNED, amount_atomic: "999999" }, { registry: makeRegistry([PUBKEY]) }), false); +}); + +test("FOLD: verifyAttestation({registry}) works on the provenance half too", async () => { + assert.equal(await verifyAttestation(SIGNED_ATT), true); // backward-compat + assert.equal(await verifyAttestation(SIGNED_ATT, { registry: makeRegistry([ATT_PUBKEY]) }), true); // valid + ours + assert.equal(await verifyAttestation(SIGNED_ATT, { registry: makeRegistry([OTHER]) }), false); // valid sig, untrusted key +}); + +test("trustedSigner: no-WebCrypto + key_id throws a DESCRIPTIVE error; a pure no-key_id lookup stays crypto-free", async () => { + // Sentry #381 review: a direct trustedSigner() in a runtime without WebCrypto (Node <18) must surface a clear + // error from the key_id self-consistency check — never an opaque `TypeError: …'subtle' of undefined`. And it + // must FAIL LOUD, not degrade to null (a null would let the {registry} fold silently pass an untrusted key). + const reg = makeRegistry([PUBKEY]); + const saved = Object.getOwnPropertyDescriptor(globalThis, "crypto"); + try { + Object.defineProperty(globalThis, "crypto", { value: undefined, configurable: true }); // simulate Node <18 + await assert.rejects(trustedSigner(SIGNED, reg), /WebCrypto unavailable/); // key_id forces sha256 -> clear throw + // a registry-membership lookup with NO key_id needs no crypto and must still decide (the guard is surgical) + assert.equal(await trustedSigner({ ...SIGNED, key_id: undefined }, reg), true); + assert.equal(await trustedSigner({ ...SIGNED, key_id: undefined }, makeRegistry([OTHER])), false); + } finally { + Object.defineProperty(globalThis, "crypto", saved); // restore for the rest of the suite + } +}); diff --git a/sdk/js/verify.js b/sdk/js/verify.js new file mode 100644 index 0000000..9a9f41b --- /dev/null +++ b/sdk/js/verify.js @@ -0,0 +1,112 @@ +// verify.js — WAVE Payment-Receipt verifier: the MONEY half of "the two receipts", offline + trustless + +// zero-dep + instance-free. Extracted to its own module (the context-attestation verifier stays in index.js) +// so the published entrypoint stays under the 300-line gate. Re-exported from index.js so the public API is +// unchanged: `import { verifyPaymentReceipt } from "@wave-av/dispatch"`. +// +// A Payment-Receipt is dispatch's signed, self-describing statement that it verified a settlement for a +// resource — the cryptographic twin of the context-attestation. Ed25519 receipts embed their own pubkey, so +// an agent confirms "I really paid for this, and dispatch verified the settlement" with NO secret and NO +// network. HMAC receipts need the shared key. The canonical is byte-identical to the edge signer +// (edge-router/payment-receipt.ts), pinned by the shared EXPECTED vector in receipt.test.js. The helper +// copies below intentionally mirror the canonical WIRE PROTOCOL (the same scheme context-attest + the edge +// re-implement and pin) — replicating it here keeps this verifier self-contained. + +function _asciiEscape(s) { + // Python's json.dumps(ensure_ascii=True) escapes every non-ASCII char as \uXXXX; JSON.stringify does not. + return s.replace(/[€-￿]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0")); +} +function _fromHex(h) { const a = new Uint8Array(h.length / 2); for (let i = 0; i < a.length; i++) a[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); return a; } +function _toHex(buf) { let o = ""; for (const x of new Uint8Array(buf)) o += x.toString(16).padStart(2, "0"); return o; } +// constant-time string compare — no length/early-exit leak (mirrors edge crypto-util.timingSafeEqual). +function _timingSafeEqual(a, b) { const n = Math.max(a.length, b.length); let m = a.length ^ b.length; for (let i = 0; i < n; i++) m |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0); return m === 0; } + +const RECEIPT_FIELDS = ["v", "ts", "protocol", "mode", "resource", "network", "asset", "amount_atomic", "pay_to", "tx_hash", "verified"]; + +/** Canonical signing string for a Payment-Receipt — byte-for-byte identical to the edge signer. */ +export function canonicalPaymentReceipt(r) { + const sorted = {}; + for (const k of [...RECEIPT_FIELDS].sort()) sorted[k] = r?.[k] ?? null; // missing -> null, like Python None + return _asciiEscape(JSON.stringify(sorted)); +} + +/** + * Verify a Payment-Receipt. Tri-state, mirroring verifyAttestation: `true`/`false` when signed & checkable, + * `null` when unsigned or an HMAC receipt given without its key. Ed25519 is self-describing (fully trustless, + * offline). Throws only if WebCrypto is unavailable (Node >=18 or a modern browser). + */ +async function _receiptSigValid(r, hmacKey) { // crypto half: is the SIGNATURE valid? (true/false/null) + if (!r || !r.alg || r.alg === "none" || !r.sig) return null; + const subtle = globalThis.crypto && globalThis.crypto.subtle; + if (!subtle) throw new Error("WebCrypto unavailable — verifyPaymentReceipt needs Node >=18 or a modern browser"); + const msg = new TextEncoder().encode(canonicalPaymentReceipt(r)); + if (r.alg === "ed25519") { + if (!r.pubkey) return null; + try { + const pub = await subtle.importKey("raw", _fromHex(r.pubkey), "Ed25519", false, ["verify"]); + return await subtle.verify("Ed25519", pub, _fromHex(r.sig), msg); // self-describing: anyone verifies + } catch { return false; } + } + if (r.alg === "hmac-sha256") { + if (!hmacKey) return null; + const key = await subtle.importKey("raw", new TextEncoder().encode(hmacKey), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + return _timingSafeEqual(_toHex(await subtle.sign("HMAC", key, msg)), r.sig); + } + return null; +} + +// ── trusted-key registry: trust's SECOND axis ("is the signer WAVE's key?", not just "is the sig valid?") ── +// A receipt/attestation embeds its own Ed25519 pubkey, so verify*() proves the SIGNATURE — but ANY keypair +// makes a valid signature. trustedSigner answers the orthogonal question: is this pubkey one WAVE PUBLISHED? +// Pure sha256 + set lookup (NO signature check — compose with verify*(), or pass {registry} to verify*() for +// the conjunction). Tri-state like verify*(): null when it cannot decide (no/empty registry, unsigned, alg +// "none", or HMAC — a PUBLIC-key registry can never vouch for a shared secret). +async function _sha256Hex(bytes) { + // Only reached from trustedSigner's key_id self-consistency check (so a registry lookup with NO key_id stays + // crypto-free). Mirror _receiptSigValid: fail LOUD on a missing WebCrypto runtime rather than throwing an + // opaque `TypeError` — and never degrade to null, which would let the {registry} fold silently pass an + // untrusted key (the fold only rejects on === false). + const subtle = globalThis.crypto && globalThis.crypto.subtle; + if (!subtle) throw new Error("WebCrypto unavailable — trustedSigner key_id check needs Node >=18 or a modern browser"); + return _toHex(await subtle.digest("SHA-256", bytes)); +} +function _isHex(s) { return typeof s === "string" && s.length % 2 === 0 && /^[0-9a-fA-F]*$/.test(s); } + +/** + * Build a trusted-key registry from entries — `{pubkey, key_id?}` objects (or bare pubkey-hex strings), or a + * `/.well-known/wave-keys.json` payload `{keys:[...]}`. Returns `{ has(pubkeyHex), size }`: a normalized set + * of lowercase-hex pubkeys. Malformed (non-hex / odd-length) entries are silently dropped. + */ +export function makeRegistry(entries) { + const list = Array.isArray(entries) ? entries : (entries && Array.isArray(entries.keys) ? entries.keys : []); + const set = new Set(); + for (const e of list) { + const pub = typeof e === "string" ? e : (e && e.pubkey); + if (_isHex(pub) && pub.length) set.add(pub.toLowerCase()); + } + return { has: (p) => typeof p === "string" && set.has(p.toLowerCase()), size: set.size }; +} + +/** + * Is the signer's key one WAVE published? `true` = ed25519 key present in `registry` AND its stated key_id + * (if any) hashes from its pubkey. `false` = a valid-shaped ed25519 key NOT in the registry, or a key_id + * that LIES about its pubkey. `null` = cannot decide (no/empty registry, unsigned, alg "none", HMAC). Does + * NOT verify the signature — that's verify*(). key_id mirrors the edge: sha256Hex(fromHex(pubkey)).slice(0,16). + */ +export async function trustedSigner(r, registry) { + if (!registry || !registry.size || !r || r.alg !== "ed25519" || !r.pubkey) return null; + const pub = String(r.pubkey).toLowerCase(); + if (!_isHex(pub)) return false; + if (r.key_id && String(r.key_id).toLowerCase() !== (await _sha256Hex(_fromHex(pub))).slice(0, 16)) return false; + return registry.has(pub); +} + +/** + * Verify a Payment-Receipt. Tri-state (true/false/null), offline + trustless. Pass `{registry}` to FOLD in + * the trusted-key check: a cryptographically-valid receipt whose key is NOT in `registry` returns `false` + * (a registry NEVER upgrades an invalid signature). Without `{registry}`, behaviour is byte-identical to before. + */ +export async function verifyPaymentReceipt(r, { hmacKey, registry } = {}) { + const v = await _receiptSigValid(r, hmacKey); + if (registry && v === true && (await trustedSigner(r, registry)) === false) return false; + return v; +} diff --git a/sdk/python/README.md b/sdk/python/README.md index 918e165..214df1a 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -1,6 +1,6 @@ # wave-dispatch (Python) -Thin client for [wave Dispatch](https://dispatch.wave.online) — route each request to the cheapest +Thin client for [WAVE Dispatch](https://dispatch.wave.online) — route each request to the cheapest capable model (local-first; escalate to your frontier only when needed). BYO keys + infra. Zero deps. ```python diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index f3de3d4..42d0bce 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "wave-dispatch" version = "0.7.0" -description = "wave Dispatch — local-first AI router. `dispatch serve` runs an OpenAI-compatible proxy that routes each request to the cheapest capable model (local-first; escalate to your frontier only when needed). BYO keys + infra." +description = "WAVE Dispatch — local-first AI router. `dispatch serve` runs an OpenAI-compatible proxy that routes each request to the cheapest capable model (local-first; escalate to your frontier only when needed). BYO keys + infra." readme = "README.md" requires-python = ">=3.8" license = { text = "MIT" } diff --git a/sdk/python/wave_dispatch/__init__.py b/sdk/python/wave_dispatch/__init__.py index 04f1803..40b9a18 100644 --- a/sdk/python/wave_dispatch/__init__.py +++ b/sdk/python/wave_dispatch/__init__.py @@ -1,4 +1,4 @@ -"""wave Dispatch — thin Python client. Route each request to the cheapest capable model (local-first; +"""WAVE Dispatch — thin Python client. Route each request to the cheapest capable model (local-first; escalate to your frontier only when needed). BYO keys + infra; the service returns a routing decision. Stdlib-only (urllib) — zero dependencies.""" import base64 @@ -8,6 +8,13 @@ import urllib.request import urllib.error from typing import Callable, Optional, Dict, Any # CR/#3: py3.8 compat (str | None is py3.10+) +# E4 verify-everywhere: offline trustless verifiers for the two receipts (canonical byte-parity to the edge +# signers + the JS SDK, pinned by shared vectors). Stdlib-only at import; Ed25519 lazily needs `cryptography`. +from .verify import ( # noqa: F401 + canonical_payment_receipt, verify_payment_receipt, + canonical_attestation, verify_attestation, attestation_truncated, + make_registry, trusted_signer, +) __version__ = "0.6.2" DEFAULT_ENDPOINT = "https://dispatch.wave.online" @@ -29,7 +36,7 @@ def _with_profile(body: dict, profile: Optional[str]) -> dict: class Dispatch: - """Client for the wave Dispatch edge API.""" + """Client for the WAVE Dispatch edge API.""" def __init__(self, license: Optional[str] = None, endpoint: str = DEFAULT_ENDPOINT, agents_endpoint: Optional[str] = None, diff --git a/sdk/python/wave_dispatch/cli.py b/sdk/python/wave_dispatch/cli.py index 60c58aa..e09349b 100644 --- a/sdk/python/wave_dispatch/cli.py +++ b/sdk/python/wave_dispatch/cli.py @@ -1,4 +1,4 @@ -"""wave Dispatch CLI — `dispatch serve` runs the local-first proxy. Installed by `pip install wave-dispatch`.""" +"""WAVE Dispatch CLI — `dispatch serve` runs the local-first proxy. Installed by `pip install wave-dispatch`.""" import sys @@ -9,7 +9,7 @@ def main(): proxy.serve() return print( - "wave Dispatch — https://dispatch.wave.online\n" + "WAVE Dispatch — https://dispatch.wave.online\n" "\n" " dispatch serve run the local-first OpenAI-compatible proxy on :8090\n" " point your agent at it: OPENAI_BASE_URL=http://localhost:8090/v1\n" diff --git a/sdk/python/wave_dispatch/proxy.py b/sdk/python/wave_dispatch/proxy.py index 84c9f08..fe32acc 100644 --- a/sdk/python/wave_dispatch/proxy.py +++ b/sdk/python/wave_dispatch/proxy.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""wave Dispatch — local-first proxy (`dispatch serve`). Speaks the OpenAI /v1/chat/completions wire +"""WAVE Dispatch — local-first proxy (`dispatch serve`). Speaks the OpenAI /v1/chat/completions wire format, so ANY agent/SDK with a custom base URL routes through it (Codex, Cursor, Continue, aider, …). Stdlib only — no extra deps. @@ -157,7 +157,7 @@ def do_POST(self): def serve(): mode = "hosted classifier (licensed)" if LICENSE else "local heuristic (no license — trivial only)" - print(f"wave Dispatch proxy :{PORT} decision={mode}") + print(f"WAVE Dispatch proxy :{PORT} decision={mode}") print(f" upstream={UPSTREAM} local={OLLAMA} ({LOCAL_MODEL})") print(f" point your agent: OPENAI_BASE_URL=http://localhost:{PORT}/v1") ThreadingHTTPServer(("127.0.0.1", PORT), _Handler).serve_forever() diff --git a/sdk/python/wave_dispatch/verify.py b/sdk/python/wave_dispatch/verify.py new file mode 100644 index 0000000..706f7f4 --- /dev/null +++ b/sdk/python/wave_dispatch/verify.py @@ -0,0 +1,139 @@ +"""verify.py — offline, trustless verifiers for the TWO RECEIPTS in the Python SDK (E4 verify-everywhere). + +Mirrors sdk/js (verifyPaymentReceipt + verifyAttestation): a Payment-Receipt is the MONEY proof ("I paid, +dispatch verified the settlement"), a context-attestation the PROVENANCE proof ("what the model saw"). An +Ed25519 record embeds its own pubkey, so anyone verifies it with NO secret and NO network; HMAC needs the +shared key. The canonical signing string is byte-for-byte identical to the edge signers +(edge-router/payment-receipt.ts, context_integrity) — pinned by the shared vectors in +tests/test_sdk_python_verify.py (the same EXPECTED constants as the JS SDK). Canonical + HMAC + tri-state are +stdlib-only; Ed25519 lazily imports `cryptography` (the optional `cdp` extra) and raises a clear error if it +is absent (mirrors the JS verifier's "WebCrypto unavailable").""" +import hmac as _hmac +import hashlib as _hashlib +import json as _json +from typing import Any, Dict, Optional + +# Field order is irrelevant (we sort) but the SET is the contract — identical to the edge signers + JS SDK. +RECEIPT_FIELDS = ["v", "ts", "protocol", "mode", "resource", "network", "asset", + "amount_atomic", "pay_to", "tx_hash", "verified"] +ATTEST_FIELDS = ["v", "ts", "model", "source", "chunk", "chunk_sha", "chunk_chars", + "num_ctx", "prompt_sha", "kept", "dropped_hallucinated"] + + +def _canonical(obj: Optional[Dict[str, Any]], fields) -> str: + """asciiEscape(JSON.stringify(sorted-fields-with-nulls)). Python's json.dumps with + separators=(",", ":") + ensure_ascii=True is byte-identical to the JS scheme: compact (no spaces), + sorted keys, every non-ASCII char as \\uXXXX. A missing field -> None -> null, exactly like JS `?? null`.""" + sorted_obj = {k: ((obj or {}).get(k)) for k in sorted(fields)} + return _json.dumps(sorted_obj, separators=(",", ":"), ensure_ascii=True) + + +def canonical_payment_receipt(r: Dict[str, Any]) -> str: + """Canonical signing string for a Payment-Receipt — byte-identical to the edge signer.""" + return _canonical(r, RECEIPT_FIELDS) + + +def canonical_attestation(att: Dict[str, Any]) -> str: + """Canonical signing string for a context-attestation — byte-identical to the Python/edge signer.""" + return _canonical(att, ATTEST_FIELDS) + + +def _verify_ed25519(pubkey_hex: str, sig_hex: str, msg: bytes) -> bool: + try: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + from cryptography.exceptions import InvalidSignature + except ImportError as e: # be explicit (like JS's WebCrypto error) — never silently treat as valid/invalid + raise RuntimeError( + "Ed25519 verify needs the `cryptography` package — pip install 'wave-dispatch[cdp]'" + ) from e + try: + Ed25519PublicKey.from_public_bytes(bytes.fromhex(pubkey_hex)).verify(bytes.fromhex(sig_hex), msg) + return True + except InvalidSignature: + return False + except Exception: + return False # malformed pubkey/sig hex -> not a valid signature + + +def _verify(obj: Optional[Dict[str, Any]], canonical_fn, hmac_key: Optional[str]) -> Optional[bool]: + if not obj or not obj.get("alg") or obj.get("alg") == "none" or not obj.get("sig"): + return None # unsigned / no-alg -> None (NOT False), mirroring the JS tri-state + msg = canonical_fn(obj).encode("utf-8") + alg = obj["alg"] + if alg == "ed25519": + pubkey = obj.get("pubkey") + if not pubkey: + return None # self-describing record missing its pubkey -> uncheckable + return _verify_ed25519(pubkey, obj["sig"], msg) + if alg == "hmac-sha256": + if not hmac_key: + return None # HMAC without the shared key -> uncheckable + want = _hmac.new(hmac_key.encode("utf-8"), msg, _hashlib.sha256).hexdigest() + return _hmac.compare_digest(want, str(obj["sig"])) # constant-time + return None + + +def _is_hex(s: Any) -> bool: + return isinstance(s, str) and len(s) % 2 == 0 and all(c in "0123456789abcdefABCDEF" for c in s) + + +def make_registry(entries: Any) -> frozenset: + """Build a trusted-key registry — a frozenset of lowercase-hex pubkeys — from a list of {pubkey, key_id?} + dicts (or bare pubkey-hex strings), or a /.well-known/wave-keys.json payload {"keys": [...]}. Malformed + (non-hex / odd-length) entries are dropped. Membership test: `pubkey_hex.lower() in registry`.""" + if isinstance(entries, dict): + entries = entries.get("keys") or [] + out = set() + for e in (entries or []): + pub = e if isinstance(e, str) else (e.get("pubkey") if isinstance(e, dict) else None) + if pub and _is_hex(pub): + out.add(pub.lower()) + return frozenset(out) + + +def trusted_signer(obj: Optional[Dict[str, Any]], registry: Any) -> Optional[bool]: + """Is the signer's key one WAVE published? Tri-state. True = ed25519 key in `registry` with an honest + key_id; False = a valid-shaped key NOT in the registry, or a key_id that LIES about its pubkey; None = + cannot decide (no/empty registry, unsigned, "none", or HMAC — a pubkey registry can't vouch for a shared + secret). Does NOT verify the signature. key_id mirrors the edge: sha256(pubkey)[:16].""" + if not registry or not obj or obj.get("alg") != "ed25519" or not obj.get("pubkey"): + return None + pub = str(obj["pubkey"]).lower() + if not _is_hex(pub): + return False + kid = obj.get("key_id") + if kid and str(kid).lower() != _hashlib.sha256(bytes.fromhex(pub)).hexdigest()[:16]: + return False + return pub in registry + + +def verify_payment_receipt(r: Dict[str, Any], hmac_key: Optional[str] = None, + registry: Any = None) -> Optional[bool]: + """Tri-state: True/False when signed & checkable, None when unsigned or an HMAC receipt without its key. + Ed25519 is self-describing -> fully offline & trustless (no secret, no network). Pass `registry` (see + make_registry) to FOLD in the trusted-key check: a valid signature from an untrusted key returns False (a + registry never upgrades a bad sig). Omit it -> behaviour is identical to before.""" + v = _verify(r, canonical_payment_receipt, hmac_key) + if registry and v is True and trusted_signer(r, registry) is False: + return False + return v + + +def verify_attestation(att: Dict[str, Any], hmac_key: Optional[str] = None, + registry: Any = None) -> Optional[bool]: + """Tri-state verifier for a context-attestation. `registry` folds in the trusted-key check (see + verify_payment_receipt).""" + v = _verify(att, canonical_attestation, hmac_key) + if registry and v is True and trusted_signer(att, registry) is False: + return False + return v + + +def attestation_truncated(att: Dict[str, Any]) -> Optional[bool]: + """True/False/None: chunk_sha != prompt_sha proves the model saw less than the full input; None when the + second hash is absent (truncation indeterminable).""" + a = att or {} + chunk_sha, prompt_sha = a.get("chunk_sha"), a.get("prompt_sha") + if not chunk_sha or not prompt_sha: + return None + return chunk_sha != prompt_sha diff --git a/sdk/ruby/lib/wave_dispatch.rb b/sdk/ruby/lib/wave_dispatch.rb index efeca66..34e2fcf 100644 --- a/sdk/ruby/lib/wave_dispatch.rb +++ b/sdk/ruby/lib/wave_dispatch.rb @@ -1,4 +1,4 @@ -# wave Dispatch — thin Ruby client. Route each request to the cheapest capable model (local-first; +# WAVE Dispatch — thin Ruby client. Route each request to the cheapest capable model (local-first; # escalate to your frontier only when needed). BYO keys + infra. Stdlib only (net/http). require "base64" require "net/http" @@ -6,6 +6,12 @@ require "uri" require "openssl" +# Offline verifiers for "the two receipts" (Payment-Receipt + context-Attestation): self-contained +# (json + openssl only) so it loads independently of the client above. Exposes the flat module API +# WaveDispatch.canonical_payment_receipt / .verify_payment_receipt / .canonical_attestation / +# .verify_attestation / .attestation_truncated. See lib/wave_dispatch/verify.rb. +require_relative "wave_dispatch/verify" + module WaveDispatch VERSION = "0.7.0" diff --git a/sdk/ruby/lib/wave_dispatch/verify.rb b/sdk/ruby/lib/wave_dispatch/verify.rb new file mode 100644 index 0000000..e28a9cb --- /dev/null +++ b/sdk/ruby/lib/wave_dispatch/verify.rb @@ -0,0 +1,203 @@ +# WAVE Dispatch — offline verifiers for "the two receipts": the Payment-Receipt (the MONEY half) and the +# context-Attestation (the CONTEXT half). Both are dispatch's signed, self-describing statements — a +# Payment-Receipt says "I paid for this and dispatch verified the settlement"; an Attestation says "this is +# exactly what the model saw, and whether the prompt was truncated". Verifying them is offline, trustless, +# instance-free, and stdlib-only (json + openssl): an Ed25519 record embeds its own public key, so anyone +# confirms it with NO secret and NO network. HMAC records need the shared key. +# +# The canonical signing string is BYTE-IDENTICAL across every implementation (JS, Python, Go, Rust, the edge +# signer, and this one), pinned by the shared cross-language vectors in test/verify_test.rb. That byte-parity +# is the whole point: "sign on the edge / in the runtime, verify in any SDK, trust no one". +# +# This file is intentionally self-contained (only json + openssl) and re-opens the WaveDispatch module, so it +# loads on its own — the published entrypoint (wave_dispatch.rb) just `require_relative`s it to expose the flat +# API. Ed25519 verify needs OpenSSL >= 1.1.1 (OpenSSL::PKey.new_raw_public_key); HMAC + canonical are universal. +# Plain `def...end` only (no endless defs) so it parses on the gemspec's declared floor (Ruby >= 2.7). +require "json" +require "openssl" + +module WaveDispatch + module Verify + # The exact field set + order semantics shared by every signer. canonical() takes ONLY these fields, + # sorts the NAMES alphabetically, inserts nil for any that are missing, then serializes compactly with + # every non-ASCII char escaped to \uXXXX (Python's json.dumps(ensure_ascii=True) shape). + RECEIPT_FIELDS = %w[v ts protocol mode resource network asset amount_atomic pay_to tx_hash verified].freeze + ATTEST_FIELDS = %w[v ts model source chunk chunk_sha chunk_chars num_ctx prompt_sha kept dropped_hallucinated].freeze + + module_function + + # Compact, ASCII-escaped JSON over exactly `fields` (sorted; missing => nil). Ruby Hash preserves insertion + # order, so inserting keys in sorted order yields sorted output. JSON.generate(ascii_only: true) is compact + # AND escapes every char >= U+0080 as \uXXXX (astral chars as a UTF-16 surrogate pair) — exactly the wire + # shape — and, unlike a browser JSON.stringify, it does NOT HTML-escape <>& (which we also must not). + def canonical(record, fields) + record = record.is_a?(Hash) ? record : {} + sorted = {} + fields.sort.each { |k| sorted[k] = record.key?(k) ? record[k] : nil } + JSON.generate(sorted, ascii_only: true) + end + + def canonical_payment_receipt(receipt) + canonical(receipt, RECEIPT_FIELDS) + end + + def canonical_attestation(att) + canonical(att, ATTEST_FIELDS) + end + + # Tri-state verify shared by both records. Returns true/false when signed & checkable, nil when the record + # is unsigned or is an HMAC record handed over without its key (uncheckable, NOT a cryptographic failure): + # nil — not a Hash; no "alg"; alg == "none"; no "sig"; ed25519 w/o "pubkey"; hmac w/o the key; unknown alg + # true/false — a real cryptographic verdict + def verify(record, canonical_str, hmac_key) + return nil unless record.is_a?(Hash) + alg = record["alg"] + sig = record["sig"] + return nil if alg.nil? || alg == "none" || sig.nil? + + case alg + when "ed25519" + pubkey = record["pubkey"] + return nil if pubkey.nil? + verify_ed25519(pubkey, sig, canonical_str) + when "hmac-sha256" + return nil if hmac_key.nil? + expected = OpenSSL::HMAC.hexdigest("SHA256", hmac_key, canonical_str) + secure_compare(expected, sig.to_s) + end # any other alg falls through to nil + end + + # Ed25519 verify from the 32 raw public-key bytes (hex) over the canonical bytes; the signature is the raw + # 64 bytes (hex). Ruby's OpenSSL exposes raw-key Ed25519 on OpenSSL >= 1.1.1; verify() takes a nil digest + # for Ed25519. Any malformed key/sig (bad hex length, wrong size, unsupported OpenSSL) is a hard false — + # never an exception, never nil (nil is reserved for "no pubkey supplied"). + def verify_ed25519(pubkey_hex, sig_hex, canonical_str) + raw_pub = [pubkey_hex.to_s].pack("H*") + sig_bytes = [sig_hex.to_s].pack("H*") + return false unless raw_pub.bytesize == 32 && sig_bytes.bytesize == 64 + key = OpenSSL::PKey.new_raw_public_key("ED25519", raw_pub) + key.verify(nil, sig_bytes, canonical_str) + rescue StandardError + false + end + + # Constant-time string compare — no length/early-exit leak (mirrors the edge crypto-util.timingSafeEqual). + # Implemented in pure Ruby so it works on every Ruby/OpenSSL build (OpenSSL.secure_compare and + # Rack::Utils.secure_compare are not universally present — e.g. a LibreSSL-linked stdlib lacks the former). + def secure_compare(a, b) + a = a.to_s.b + b = b.to_s.b + n = [a.bytesize, b.bytesize].max + mismatch = a.bytesize ^ b.bytesize + n.times { |i| mismatch |= (a.getbyte(i) || 0) ^ (b.getbyte(i) || 0) } + mismatch.zero? + end + + # E4.followup — trusted-key registry: trust's SECOND axis ("is the signer one WAVE published?"). hex?, + # make_registry, trusted_signer mirror sdk/js/verify.js byte-for-byte (pinned by the shared FIXED vector). + def hex?(s) + s.is_a?(String) && s.length.even? && s.match?(/\A[0-9a-fA-F]*\z/) + end + + # Build a trusted-key registry (a Hash used as a SET of lowercase-hex pubkeys) from a list of + # {pubkey, key_id?} hashes (or bare pubkey-hex strings), or a /.well-known/wave-keys.json payload + # {"keys" => [...]}. Malformed (non-hex / odd-length) entries are dropped. Membership: registry.key?(pub). + def make_registry(entries) + entries = entries["keys"] if entries.is_a?(Hash) + set = {} + Array(entries).each do |e| + pub = e.is_a?(String) ? e : (e.is_a?(Hash) ? e["pubkey"] : nil) + set[pub.downcase] = true if pub.is_a?(String) && !pub.empty? && hex?(pub) + end + set + end + + # key_id mirrors the edge derivation: sha256(pubkey-bytes)[0, 16] hex. + def key_id_of(pubkey_hex) + OpenSSL::Digest.hexdigest("SHA256", [pubkey_hex].pack("H*"))[0, 16] + end + + # Is the signer's key one WAVE published? Tri-state. true = ed25519 key in `registry` with an honest + # key_id; false = a valid-shaped key NOT in the registry, or a key_id that LIES about its pubkey; nil = + # cannot decide (no/empty registry, unsigned, "none", HMAC). Does NOT verify the signature. + def trusted_signer(record, registry) + return nil unless registry.is_a?(Hash) && !registry.empty? && record.is_a?(Hash) + return nil unless record["alg"] == "ed25519" + + pub = record["pubkey"] + return nil if pub.nil? || pub.to_s.empty? + + pub = pub.to_s.downcase + return false unless hex?(pub) + + kid = record["key_id"] + return false if kid && !kid.to_s.empty? && kid.to_s.downcase != key_id_of(pub) + + registry.key?(pub) + end + + # Public: verify a Payment-Receipt. `hmac_key:` only for HMAC receipts (Ed25519 is self-describing). Pass + # `registry:` (see make_registry) to FOLD in the trusted-key check: a valid signature from an untrusted key + # returns false (a registry never upgrades a bad sig). Omit it -> behaviour is identical to before. + def verify_payment_receipt(receipt, hmac_key: nil, registry: nil) + v = verify(receipt, canonical_payment_receipt(receipt), hmac_key) + return false if registry && v == true && trusted_signer(receipt, registry) == false + + v + end + + # Public: verify a context-Attestation. `registry:` folds in the trusted-key check (see verify_payment_receipt). + def verify_attestation(att, hmac_key: nil, registry: nil) + v = verify(att, canonical_attestation(att), hmac_key) + return false if registry && v == true && trusted_signer(att, registry) == false + + v + end + + # Public: did the model see less than the full prompt? chunk_sha vs prompt_sha — both present & differ => + # true; equal => false; either missing => nil (undeterminable). Pure data, no crypto. + def attestation_truncated(att) + return nil unless att.is_a?(Hash) + c = att["chunk_sha"] + p = att["prompt_sha"] + return nil if c.nil? || p.nil? + c != p + end + end + + # Re-export at the module top level so the public API is flat: + # WaveDispatch.canonical_payment_receipt(r) / .verify_payment_receipt(r, hmac_key:) / + # WaveDispatch.canonical_attestation(a) / .verify_attestation(a, hmac_key:) / .attestation_truncated(a) + # Explicit delegators (not metaprogrammed) so keyword forwarding is correct on every Ruby — a `**kwargs` + # splat-forward mis-separates a trailing positional Hash on Ruby 2.6/2.7, and `def self.x = ...` endless + # defs would break the gemspec's declared floor (>= 2.7). + class << self + def canonical_payment_receipt(receipt) + Verify.canonical_payment_receipt(receipt) + end + + def canonical_attestation(att) + Verify.canonical_attestation(att) + end + + def verify_payment_receipt(receipt, hmac_key: nil, registry: nil) + Verify.verify_payment_receipt(receipt, hmac_key: hmac_key, registry: registry) + end + + def verify_attestation(att, hmac_key: nil, registry: nil) + Verify.verify_attestation(att, hmac_key: hmac_key, registry: registry) + end + + def make_registry(entries) + Verify.make_registry(entries) + end + + def trusted_signer(record, registry) + Verify.trusted_signer(record, registry) + end + + def attestation_truncated(att) + Verify.attestation_truncated(att) + end + end +end diff --git a/sdk/ruby/test/verify_test.rb b/sdk/ruby/test/verify_test.rb new file mode 100644 index 0000000..c182cda --- /dev/null +++ b/sdk/ruby/test/verify_test.rb @@ -0,0 +1,155 @@ +# Zero-dep tests (minitest, stdlib-bundled) for the SDK's offline verifiers — "the two receipts". The +# decisive assertions are CROSS-IMPLEMENTATION: every EXPECTED_* constant below is the SAME byte-exact vector +# pinned in the JS (receipt.test.js / attest.test.js), the edge signer, and the other SDKs. Green here proves +# this Ruby SDK produces a byte-for-byte identical canonical string and verifies the very signatures the +# Python signer emitted — "sign on the edge / in the runtime, verify in any SDK, trust no one". +# +# Ed25519 verify needs OpenSSL >= 1.1.1 (OpenSSL::PKey.new_raw_public_key). On a host whose Ruby is linked +# against LibreSSL or an older OpenSSL, that method is absent and the Ed25519-specific cases SKIP (the canonical +# pin, HMAC, and tri-state cases — all pure json/openssl-HMAC — always run). See ED25519_AVAILABLE below. +require "minitest/autorun" +require_relative "../lib/wave_dispatch/verify" + +ED25519_AVAILABLE = OpenSSL::PKey.respond_to?(:new_raw_public_key) + +class VerifyTest < Minitest::Test + # ── Payment-Receipt (the MONEY half) ────────────────────────────────────────────────────────────── + RECEIPT = { + "v" => "wave.payment-receipt/v0", "ts" => 1700000000, "protocol" => "x402", "mode" => "wave-x402", + "resource" => "/extract", "network" => "base", "asset" => "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "amount_atomic" => "1000", "pay_to" => "0x0000000000000000000000000000000000000001", + "tx_hash" => "0xdeadbeef", "verified" => true + }.freeze + EXPECTED_RECEIPT_CANONICAL = + '{"amount_atomic":"1000","asset":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",' \ + '"mode":"wave-x402","network":"base","pay_to":"0x0000000000000000000000000000000000000001",' \ + '"protocol":"x402","resource":"/extract","ts":1700000000,"tx_hash":"0xdeadbeef",' \ + '"v":"wave.payment-receipt/v0","verified":true}' + RECEIPT_PUBKEY = "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c" + RECEIPT_SIG = + "e333772435ba2b16c9b52188a489de18f0dbba870a84b6dcc6fe63ef3dffae5a" \ + "b9e65daa85b3306d4d3df879bc47c6282f87c661e634390a9a5d93e7c502220a" + + # ── context-Attestation (the CONTEXT half) ──────────────────────────────────────────────────────── + ATTEST = { + "v" => "wave.context-attestation/v0", "ts" => 1700000000, "model" => "qwen3:30b", "source" => "café.md", + "chunk" => 2, "chunk_sha" => "deadbeef", "chunk_chars" => 2980, "num_ctx" => 8192, + "prompt_sha" => "cafe1234", "kept" => 3, "dropped_hallucinated" => 1 + }.freeze + # The café -> café ascii-escape pin: byte-exact with the Python signer (ensure_ascii=True). + EXPECTED_ATTEST_CANONICAL = + '{"chunk":2,"chunk_chars":2980,"chunk_sha":"deadbeef","dropped_hallucinated":1,"kept":3,' \ + '"model":"qwen3:30b","num_ctx":8192,"prompt_sha":"cafe1234","source":"caf\\u00e9.md",' \ + '"ts":1700000000,"v":"wave.context-attestation/v0"}' + ATTEST_PUBKEY = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8" + ATTEST_SIG = + "68017540b4c3b060e51faab64c28f4e1ea6994d3781fec14878e73052046d78d" \ + "05757a8287af644b245b10274ba3b60329ed216472839d7573debf4256e9730b" + + # ── canonical byte-parity (always runs — pure json) ─────────────────────────────────────────────── + def test_receipt_canonical_is_byte_identical_to_edge_signer + assert_equal EXPECTED_RECEIPT_CANONICAL, WaveDispatch.canonical_payment_receipt(RECEIPT) + end + + def test_attestation_canonical_matches_python_signer_cafe_escape + assert_equal EXPECTED_ATTEST_CANONICAL, WaveDispatch.canonical_attestation(ATTEST) + end + + # ── Ed25519: trustless + offline, self-describing (gated on OpenSSL capability) ──────────────────── + def test_receipt_ed25519_verifies_with_no_secret_and_tamper_fails + skip "Ed25519 unavailable (need OpenSSL >= 1.1.1; this Ruby links #{OpenSSL::OPENSSL_VERSION})" unless ED25519_AVAILABLE + signed = RECEIPT.merge("alg" => "ed25519", "sig" => RECEIPT_SIG, "pubkey" => RECEIPT_PUBKEY) + assert_equal true, WaveDispatch.verify_payment_receipt(signed) + assert_equal false, WaveDispatch.verify_payment_receipt(signed.merge("amount_atomic" => "999999")) # tampered money + assert_equal false, WaveDispatch.verify_payment_receipt(signed.merge("verified" => false)) # tampered flag + end + + def test_attestation_ed25519_verifies_python_signature_and_tamper_fails + skip "Ed25519 unavailable (need OpenSSL >= 1.1.1; this Ruby links #{OpenSSL::OPENSSL_VERSION})" unless ED25519_AVAILABLE + signed = ATTEST.merge("alg" => "ed25519", "sig" => ATTEST_SIG, "pubkey" => ATTEST_PUBKEY) + assert_equal true, WaveDispatch.verify_attestation(signed) + assert_equal false, WaveDispatch.verify_attestation(signed.merge("kept" => 999)) # tampered field + end + + def test_ed25519_without_pubkey_is_nil + skip "Ed25519 unavailable" unless ED25519_AVAILABLE + assert_nil WaveDispatch.verify_payment_receipt(RECEIPT.merge("alg" => "ed25519", "sig" => RECEIPT_SIG)) + assert_nil WaveDispatch.verify_attestation(ATTEST.merge("alg" => "ed25519", "sig" => ATTEST_SIG)) + end + + # ── HMAC tri-state (always runs — openssl HMAC is in every Ruby/OpenSSL build) ───────────────────── + def test_receipt_hmac_verifies_with_key_fails_wrong_nil_without + sig = OpenSSL::HMAC.hexdigest("SHA256", "k", WaveDispatch.canonical_payment_receipt(RECEIPT)) + r = RECEIPT.merge("alg" => "hmac-sha256", "sig" => sig) + assert_equal true, WaveDispatch.verify_payment_receipt(r, hmac_key: "k") + assert_equal false, WaveDispatch.verify_payment_receipt(r, hmac_key: "wrong") + assert_nil WaveDispatch.verify_payment_receipt(r) # HMAC without our key -> uncheckable + end + + def test_attestation_hmac_verifies_with_key_fails_wrong_nil_without + sig = OpenSSL::HMAC.hexdigest("SHA256", "k", WaveDispatch.canonical_attestation(ATTEST)) + att = ATTEST.merge("alg" => "hmac-sha256", "sig" => sig) + assert_equal true, WaveDispatch.verify_attestation(att, hmac_key: "k") + assert_equal false, WaveDispatch.verify_attestation(att, hmac_key: "wrong") + assert_nil WaveDispatch.verify_attestation(att) + end + + # ── unsigned / no-alg -> nil (not false) (always runs) ───────────────────────────────────────────── + def test_unsigned_records_are_nil + assert_nil WaveDispatch.verify_payment_receipt(RECEIPT) + assert_nil WaveDispatch.verify_payment_receipt(RECEIPT.merge("alg" => "none", "sig" => nil)) + assert_nil WaveDispatch.verify_attestation(ATTEST) + assert_nil WaveDispatch.verify_attestation(ATTEST.merge("alg" => "none", "sig" => nil)) + end + + def test_non_hash_and_unknown_alg_are_nil + assert_nil WaveDispatch.verify_payment_receipt("not a hash") + assert_nil WaveDispatch.verify_attestation(nil) + assert_nil WaveDispatch.verify_payment_receipt(RECEIPT.merge("alg" => "rsa-pss", "sig" => "ab")) + end + + # ── truncation tri-state (always runs — pure data) ───────────────────────────────────────────────── + def test_truncation_is_derivable_from_two_hashes + assert_equal true, WaveDispatch.attestation_truncated("chunk_sha" => "a", "prompt_sha" => "b") + assert_equal false, WaveDispatch.attestation_truncated("chunk_sha" => "a", "prompt_sha" => "a") + assert_nil WaveDispatch.attestation_truncated("chunk_sha" => "a") + assert_nil WaveDispatch.attestation_truncated("prompt_sha" => "b") + end + + # ── E4.followup: trusted-key registry (the SECOND axis). Cross-impl with sdk/js/trusted.test.js ────── + TRUSTED_KEY_ID = "34750f98bd59fcfc" # = sha256(RECEIPT_PUBKEY bytes)[0, 16] — the edge derivation + OTHER_KEY = "00" * 32 + + def test_make_registry_membership_and_wellknown_payload + reg = WaveDispatch.make_registry([{ "pubkey" => RECEIPT_PUBKEY, "key_id" => TRUSTED_KEY_ID }]) + assert_equal 1, reg.size + assert reg.key?(RECEIPT_PUBKEY) # stored lowercase + refute reg.key?(OTHER_KEY) + assert_equal 1, WaveDispatch.make_registry("keys" => [RECEIPT_PUBKEY, "nothex!!", "abc"]).size + assert_equal 0, WaveDispatch.make_registry(nil).size + end + + def test_trusted_signer_tristate_and_keyid_lie + signed = { "alg" => "ed25519", "pubkey" => RECEIPT_PUBKEY, "key_id" => TRUSTED_KEY_ID, "sig" => "ab" } + reg = WaveDispatch.make_registry([RECEIPT_PUBKEY]) + assert_equal true, WaveDispatch.trusted_signer(signed, reg) + assert_equal true, WaveDispatch.trusted_signer(signed.merge("pubkey" => RECEIPT_PUBKEY.upcase), reg) # case-insensitive + assert_equal false, WaveDispatch.trusted_signer(signed, WaveDispatch.make_registry([OTHER_KEY])) + assert_nil WaveDispatch.trusted_signer(signed, nil) + assert_nil WaveDispatch.trusted_signer(signed, {}) + assert_nil WaveDispatch.trusted_signer(signed.merge("alg" => "hmac-sha256"), reg) + assert_equal false, WaveDispatch.trusted_signer(signed.merge("key_id" => "deadbeefdeadbeef"), reg) # key_id lie + assert_equal true, WaveDispatch.trusted_signer(signed.merge("key_id" => nil), reg) # absent key_id ok + end + + def test_registry_folds_into_verify + skip "Ed25519 unavailable" unless ED25519_AVAILABLE + + signed = RECEIPT.merge("alg" => "ed25519", "sig" => RECEIPT_SIG, "pubkey" => RECEIPT_PUBKEY) + assert_equal true, WaveDispatch.verify_payment_receipt(signed) # no registry -> unchanged + assert_equal true, WaveDispatch.verify_payment_receipt(signed, registry: WaveDispatch.make_registry([RECEIPT_PUBKEY])) + assert_equal false, WaveDispatch.verify_payment_receipt(signed, registry: WaveDispatch.make_registry([OTHER_KEY])) # valid sig, untrusted + assert_equal false, WaveDispatch.verify_payment_receipt(signed.merge("amount_atomic" => "999999"), + registry: WaveDispatch.make_registry([RECEIPT_PUBKEY])) # bad sig not upgraded + end +end diff --git a/sdk/ruby/wave_dispatch.gemspec b/sdk/ruby/wave_dispatch.gemspec index 140310d..7b28324 100644 --- a/sdk/ruby/wave_dispatch.gemspec +++ b/sdk/ruby/wave_dispatch.gemspec @@ -1,12 +1,12 @@ Gem::Specification.new do |s| s.name = "wave-dispatch" s.version = "0.7.0" - s.summary = "wave Dispatch — local-first AI router client" + s.summary = "WAVE Dispatch — local-first AI router client" s.description = "Route each request to the cheapest capable model (local-first; escalate to your frontier only when needed). BYO keys + infra." s.authors = ["WAVE Online, LLC"] s.homepage = "https://dispatch.wave.online" s.license = "MIT" - s.files = ["lib/wave_dispatch.rb"] + s.files = ["lib/wave_dispatch.rb", "lib/wave_dispatch/verify.rb"] s.require_paths = ["lib"] s.required_ruby_version = ">= 2.7" s.metadata = { "source_code_uri" => "https://github.com/wave-av/dispatch-edge" } diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml index acbbb66..5756577 100644 --- a/sdk/rust/Cargo.toml +++ b/sdk/rust/Cargo.toml @@ -2,7 +2,7 @@ name = "wave-dispatch" version = "0.7.0" edition = "2021" -description = "wave Dispatch — local-first AI router client. Route each request to the cheapest capable model (local-first; escalate to your frontier only when needed). BYO keys + infra." +description = "WAVE Dispatch — local-first AI router client. Route each request to the cheapest capable model (local-first; escalate to your frontier only when needed). BYO keys + infra." license = "MIT" repository = "https://github.com/wave-av/dispatch-edge" homepage = "https://dispatch.wave.online" @@ -16,3 +16,7 @@ base64 = "0.22" # 0.5.1 — wallet_hook(privy) needs Basic auth encodi urlencoding = "2" # 0.5.1 — wallet_hook(privy) URL-encodes the wallet_id path segment p256 = { version = "0.13", features = ["pkcs8", "ecdsa"] } # 0.6.2 — CDP-JWT (ES256) signing rand_core = "0.6" # 0.6.2 — nonce randomness for CDP-JWT +ed25519-dalek = "2" # E4 — verify the two receipts' Ed25519 signatures (offline, trustless) +hmac = "0.12" # E4 — HMAC-SHA256 receipt/attestation verify (constant-time verify_slice) +sha2 = "0.10" # E4 — SHA-256 for the HMAC path +hex = "0.4" # E4 — decode the hex pubkey/sig/hmac diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index 814c16f..c462bdb 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -1,10 +1,19 @@ -//! wave Dispatch — thin Rust client. Route each request to the cheapest capable model (local-first; +//! WAVE Dispatch — thin Rust client. Route each request to the cheapest capable model (local-first; //! escalate to your frontier only when needed). BYO keys + infra; the service returns a routing decision. use serde_json::{json, Value}; use std::collections::HashMap; use std::error::Error; use std::time::{SystemTime, UNIX_EPOCH}; +// E4 verify-everywhere: offline trustless verifiers for the two receipts (canonical byte-parity to the edge +// signers + the other SDKs, pinned by shared vectors). Public API re-exported flat. +mod verify; +pub use verify::{ + attestation_truncated, canonical_attestation, canonical_payment_receipt, make_registry, + trusted_signer, verify_attestation, verify_attestation_trusted, verify_payment_receipt, + verify_payment_receipt_trusted, Registry, +}; + /// 0.6.2 — CDP-JWT (ES256/P-256) signer. Uses the `p256` crate (added to dependencies). Pure Rust; /// no openssl FFI. Header: {alg:'ES256', kid:, typ:'JWT', nonce:}. Payload: /// {sub:, iss:'cdp', nbf:, exp:, uri:'POST dispatch.wave.online', claim:}. diff --git a/sdk/rust/src/verify.rs b/sdk/rust/src/verify.rs new file mode 100644 index 0000000..7ddf7ac --- /dev/null +++ b/sdk/rust/src/verify.rs @@ -0,0 +1,445 @@ +// SPLIT-PLAN: the runtime code (canonical/verify/trusted/fold) is ~160 lines; the rest is `#[cfg(test)]` — +// the Rust convention of inline tests. Splitting these into a separate file is the OPPOSITE of idiomatic and +// would only obscure the cross-impl vector pin (the whole point of this module). Architecture is correct. +//! verify.rs — offline, trustless verifiers for "the two receipts" in the Rust SDK (E4 verify-everywhere). +//! +//! A Payment-Receipt is the MONEY proof ("I paid, dispatch verified the settlement"); a context-Attestation +//! is the PROVENANCE proof ("what the model saw"). An Ed25519 record embeds its own public key, so anyone +//! verifies it with NO secret and NO network; HMAC records need the shared key. The canonical signing string +//! is byte-for-byte identical to every other implementation (JS/Python/Go/Ruby + the edge signers), pinned by +//! the shared vectors in the `tests` module below. +use ed25519_dalek::{Signature, VerifyingKey}; +use hmac::{Hmac, Mac}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashSet}; + +type HmacSha256 = Hmac; + +// The exact field sets shared by every signer. canonical() uses ONLY these, sorted (BTreeMap), missing => null. +const RECEIPT_FIELDS: [&str; 11] = [ + "v", + "ts", + "protocol", + "mode", + "resource", + "network", + "asset", + "amount_atomic", + "pay_to", + "tx_hash", + "verified", +]; +const ATTEST_FIELDS: [&str; 11] = [ + "v", + "ts", + "model", + "source", + "chunk", + "chunk_sha", + "chunk_chars", + "num_ctx", + "prompt_sha", + "kept", + "dropped_hallucinated", +]; + +// Compact, key-sorted, ASCII-escaped JSON over exactly `fields`. A BTreeMap serializes its keys in sorted +// order; serde_json::to_string is compact and does NOT HTML-escape <>& (which we also must not); ascii_escape +// then matches the JS asciiEscape(JSON.stringify) / Python ensure_ascii shape. +fn canonical(record: &Value, fields: &[&str]) -> String { + let mut sorted: BTreeMap<&str, Value> = BTreeMap::new(); + for &k in fields { + sorted.insert(k, record.get(k).cloned().unwrap_or(Value::Null)); + } + ascii_escape(&serde_json::to_string(&sorted).unwrap_or_default()) +} + +// Rewrite every char >= U+0080 as \uXXXX (lowercase, 4 hex digits); chars above U+FFFF become a UTF-16 +// surrogate pair, matching JS JSON's UTF-16 code-unit escaping and Python's ensure_ascii. +fn ascii_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + let cp = c as u32; + if cp < 0x80 { + out.push(c); + } else if cp <= 0xFFFF { + out.push_str(&format!("\\u{:04x}", cp)); + } else { + let mut buf = [0u16; 2]; + for &unit in c.encode_utf16(&mut buf).iter() { + out.push_str(&format!("\\u{:04x}", unit)); + } + } + } + out +} + +/// Canonical signing string for a Payment-Receipt — byte-identical to the edge signer. +pub fn canonical_payment_receipt(receipt: &Value) -> String { + canonical(receipt, &RECEIPT_FIELDS) +} + +/// Canonical signing string for a context-Attestation. +pub fn canonical_attestation(att: &Value) -> String { + canonical(att, &ATTEST_FIELDS) +} + +// Tri-state core: None when unsigned / no alg / alg "none" / no sig / ed25519 without pubkey / hmac without +// the key / unknown alg; otherwise a real true/false crypto verdict. +fn verify_record(record: &Value, canonical_str: &str, hmac_key: Option<&str>) -> Option { + let alg = record.get("alg").and_then(Value::as_str)?; + let sig = record.get("sig").and_then(Value::as_str)?; + if alg.is_empty() || alg == "none" { + return None; + } + match alg { + "ed25519" => { + let pubkey = record.get("pubkey").and_then(Value::as_str)?; // None if no pubkey + Some(verify_ed25519(pubkey, sig, canonical_str.as_bytes())) + } + "hmac-sha256" => { + let key = hmac_key?; // None if the key was not supplied + Some(verify_hmac(key, sig, canonical_str.as_bytes())) + } + _ => None, + } +} + +// Raw 32-byte public key (hex) + raw 64-byte signature (hex) over the canonical bytes. Any malformed hex / +// wrong length is a hard false (never a panic) — None is reserved for "no pubkey supplied". +fn verify_ed25519(pubkey_hex: &str, sig_hex: &str, msg: &[u8]) -> bool { + let pub_bytes = match hex::decode(pubkey_hex) { + Ok(b) => b, + Err(_) => return false, + }; + let sig_bytes = match hex::decode(sig_hex) { + Ok(b) => b, + Err(_) => return false, + }; + let pub_arr: [u8; 32] = match pub_bytes.try_into() { + Ok(a) => a, + Err(_) => return false, + }; + let sig_arr: [u8; 64] = match sig_bytes.try_into() { + Ok(a) => a, + Err(_) => return false, + }; + match VerifyingKey::from_bytes(&pub_arr) { + Ok(vk) => vk + .verify_strict(msg, &Signature::from_bytes(&sig_arr)) + .is_ok(), + Err(_) => false, + } +} + +fn verify_hmac(key: &str, sig_hex: &str, msg: &[u8]) -> bool { + let sig_bytes = match hex::decode(sig_hex) { + Ok(b) => b, + Err(_) => return false, + }; + let mut mac = match HmacSha256::new_from_slice(key.as_bytes()) { + Ok(m) => m, + Err(_) => return false, + }; + mac.update(msg); + mac.verify_slice(&sig_bytes).is_ok() // constant-time +} + +/// Verify a Payment-Receipt. `Some(true)`/`Some(false)` when signed & checkable, `None` when unsigned or an +/// HMAC receipt without its key. Ed25519 is self-describing — offline & trustless (no secret, no network). +pub fn verify_payment_receipt(receipt: &Value, hmac_key: Option<&str>) -> Option { + verify_record(receipt, &canonical_payment_receipt(receipt), hmac_key) +} + +/// Verify a context-Attestation (same tri-state scheme as `verify_payment_receipt`). +pub fn verify_attestation(att: &Value, hmac_key: Option<&str>) -> Option { + verify_record(att, &canonical_attestation(att), hmac_key) +} + +/// Did the model see less than the full prompt? `chunk_sha` != `prompt_sha` => `Some(true)`, equal => +/// `Some(false)`, either missing => `None` (undeterminable). Pure data, no crypto. +pub fn attestation_truncated(att: &Value) -> Option { + let c = att.get("chunk_sha").and_then(Value::as_str); + let p = att.get("prompt_sha").and_then(Value::as_str); + match (c, p) { + (Some(c), Some(p)) if !c.is_empty() && !p.is_empty() => Some(c != p), + _ => None, + } +} + +// ── trusted-key registry (E4.followup): trust's SECOND axis — "is the signer one WAVE published?" ── +// trusted_signer answers registry membership + key_id honesty (NOT the signature; compose with verify_*, or +// use verify_*_trusted for the conjunction). Rust has no optional args, so the fold is a sibling fn rather +// than an extra param. Mirrors sdk/js/verify.js byte-for-byte (pinned by the shared FIXED vector). + +/// A set of trusted signer pubkeys (lowercase hex), built by [`make_registry`]. +pub struct Registry(HashSet); + +impl Registry { + /// Number of distinct trusted keys. + pub fn len(&self) -> usize { + self.0.len() + } + /// True when the registry holds no keys (it then decides nothing). + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + /// Is `pubkey_hex` (case-insensitive) a trusted key? + pub fn has(&self, pubkey_hex: &str) -> bool { + self.0.contains(&pubkey_hex.to_lowercase()) + } +} + +fn is_hex(s: &str) -> bool { + s.len() % 2 == 0 && s.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// Build a trusted-key registry from a serde_json value: an array of pubkey-hex strings or `{pubkey, key_id?}` +/// objects, or a `/.well-known/wave-keys.json` payload `{"keys": [...]}`. Malformed entries are dropped. +pub fn make_registry(entries: &Value) -> Registry { + let list = entries.get("keys").unwrap_or(entries); + let mut set = HashSet::new(); + if let Some(arr) = list.as_array() { + for e in arr { + let pubkey = e.as_str().or_else(|| e.get("pubkey").and_then(Value::as_str)); + if let Some(p) = pubkey { + if !p.is_empty() && is_hex(p) { + set.insert(p.to_lowercase()); + } + } + } + } + Registry(set) +} + +fn key_id_of(pubkey_hex: &str) -> String { + match hex::decode(pubkey_hex) { + Ok(bytes) => hex::encode(Sha256::digest(&bytes))[..16].to_string(), // the edge derivation + Err(_) => String::new(), + } +} + +/// Is the signer's key one WAVE published? Tri-state: `Some(true)` = ed25519 key in `registry` with an honest +/// key_id; `Some(false)` = a valid-shaped key NOT in the registry, or a key_id that lies about its pubkey; +/// `None` = cannot decide (empty registry, unsigned, "none", or HMAC). Does NOT verify the signature. +pub fn trusted_signer(record: &Value, registry: &Registry) -> Option { + if registry.is_empty() || record.get("alg").and_then(Value::as_str) != Some("ed25519") { + return None; + } + let pubkey = record + .get("pubkey") + .and_then(Value::as_str) + .filter(|s| !s.is_empty())?; + let pub_lc = pubkey.to_lowercase(); + if !is_hex(&pub_lc) { + return Some(false); + } + if let Some(kid) = record.get("key_id").and_then(Value::as_str) { + if kid.to_lowercase() != key_id_of(&pub_lc) { + return Some(false); // key_id lies about its pubkey + } + } + Some(registry.has(&pub_lc)) +} + +fn fold(v: Option, record: &Value, registry: &Registry) -> Option { + if v == Some(true) && trusted_signer(record, registry) == Some(false) { + return Some(false); // valid signature, but the key is not WAVE's + } + v // a registry never upgrades an invalid signature +} + +/// Verify a Payment-Receipt AND require the signer be in `registry` (the trusted-key fold): a valid signature +/// from an untrusted key returns `Some(false)`. For the signature-only check use [`verify_payment_receipt`]. +pub fn verify_payment_receipt_trusted( + receipt: &Value, + registry: &Registry, + hmac_key: Option<&str>, +) -> Option { + fold(verify_payment_receipt(receipt, hmac_key), receipt, registry) +} + +/// Verify a context-Attestation with the trusted-key fold (see [`verify_payment_receipt_trusted`]). +pub fn verify_attestation_trusted( + att: &Value, + registry: &Registry, + hmac_key: Option<&str>, +) -> Option { + fold(verify_attestation(att, hmac_key), att, registry) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn fixed_receipt() -> Value { + json!({"v":"wave.payment-receipt/v0","ts":1700000000,"protocol":"x402","mode":"wave-x402","resource":"/extract","network":"base","asset":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","amount_atomic":"1000","pay_to":"0x0000000000000000000000000000000000000001","tx_hash":"0xdeadbeef","verified":true}) + } + const EXPECTED_RECEIPT_CANONICAL: &str = r#"{"amount_atomic":"1000","asset":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","mode":"wave-x402","network":"base","pay_to":"0x0000000000000000000000000000000000000001","protocol":"x402","resource":"/extract","ts":1700000000,"tx_hash":"0xdeadbeef","v":"wave.payment-receipt/v0","verified":true}"#; + const RECEIPT_PUBKEY: &str = "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c"; + const RECEIPT_SIG: &str = "e333772435ba2b16c9b52188a489de18f0dbba870a84b6dcc6fe63ef3dffae5ab9e65daa85b3306d4d3df879bc47c6282f87c661e634390a9a5d93e7c502220a"; + + fn base_att() -> Value { + json!({"v":"wave.context-attestation/v0","ts":1700000000,"model":"qwen3:30b","source":"café.md","chunk":2,"chunk_sha":"deadbeef","chunk_chars":2980,"num_ctx":8192,"prompt_sha":"cafe1234","kept":3,"dropped_hallucinated":1}) + } + // café (real é in the source field) -> café in the canonical OUTPUT (raw string keeps it as 6 literal chars). + const PY_ATT_CANONICAL: &str = r#"{"chunk":2,"chunk_chars":2980,"chunk_sha":"deadbeef","dropped_hallucinated":1,"kept":3,"model":"qwen3:30b","num_ctx":8192,"prompt_sha":"cafe1234","source":"caf\u00e9.md","ts":1700000000,"v":"wave.context-attestation/v0"}"#; + const ATT_PUBKEY: &str = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8"; + const ATT_SIG: &str = "68017540b4c3b060e51faab64c28f4e1ea6994d3781fec14878e73052046d78d05757a8287af644b245b10274ba3b60329ed216472839d7573debf4256e9730b"; + + fn with(base: &Value, over: Value) -> Value { + let mut m = base.as_object().unwrap().clone(); + for (k, v) in over.as_object().unwrap() { + m.insert(k.clone(), v.clone()); + } + Value::Object(m) + } + fn hmac_hex(key: &str, msg: &str) -> String { + let mut mac = HmacSha256::new_from_slice(key.as_bytes()).unwrap(); + mac.update(msg.as_bytes()); + hex::encode(mac.finalize().into_bytes().to_vec()) + } + + #[test] + fn receipt_canonical() { + assert_eq!( + canonical_payment_receipt(&fixed_receipt()), + EXPECTED_RECEIPT_CANONICAL + ); + } + #[test] + fn attestation_canonical_cafe() { + assert_eq!(canonical_attestation(&base_att()), PY_ATT_CANONICAL); + } + #[test] + fn receipt_ed25519() { + let signed = with( + &fixed_receipt(), + json!({"alg":"ed25519","sig":RECEIPT_SIG,"pubkey":RECEIPT_PUBKEY}), + ); + assert_eq!(verify_payment_receipt(&signed, None), Some(true)); + assert_eq!( + verify_payment_receipt(&with(&signed, json!({"amount_atomic":"999999"})), None), + Some(false) + ); + assert_eq!( + verify_payment_receipt(&with(&signed, json!({"verified":false})), None), + Some(false) + ); + } + #[test] + fn attestation_ed25519() { + let signed = with( + &base_att(), + json!({"alg":"ed25519","sig":ATT_SIG,"pubkey":ATT_PUBKEY}), + ); + assert_eq!(verify_attestation(&signed, None), Some(true)); + assert_eq!( + verify_attestation(&with(&signed, json!({"kept":999})), None), + Some(false) + ); + } + #[test] + fn hmac_tristate() { + let r = with( + &fixed_receipt(), + json!({"alg":"hmac-sha256","sig":hmac_hex("k", EXPECTED_RECEIPT_CANONICAL)}), + ); + assert_eq!(verify_payment_receipt(&r, Some("k")), Some(true)); + assert_eq!(verify_payment_receipt(&r, Some("wrong")), Some(false)); + assert_eq!(verify_payment_receipt(&r, None), None); + let a = with( + &base_att(), + json!({"alg":"hmac-sha256","sig":hmac_hex("k", PY_ATT_CANONICAL)}), + ); + assert_eq!(verify_attestation(&a, Some("k")), Some(true)); + assert_eq!(verify_attestation(&a, None), None); + } + #[test] + fn unsigned() { + assert_eq!(verify_payment_receipt(&fixed_receipt(), None), None); + assert_eq!( + verify_payment_receipt( + &with(&fixed_receipt(), json!({"alg":"none","sig":null})), + None + ), + None + ); + assert_eq!(verify_attestation(&base_att(), None), None); + } + #[test] + fn truncated() { + assert_eq!( + attestation_truncated(&json!({"chunk_sha":"a","prompt_sha":"b"})), + Some(true) + ); + assert_eq!( + attestation_truncated(&json!({"chunk_sha":"a","prompt_sha":"a"})), + Some(false) + ); + assert_eq!(attestation_truncated(&json!({"chunk_sha":"a"})), None); + } + + // ── trusted-key registry (E4.followup) — same FIXED vector as sdk/js/trusted.test.js + the other ports ── + const KEY_ID: &str = "34750f98bd59fcfc"; // sha256(RECEIPT_PUBKEY bytes)[:16] — the edge derivation + const OTHER: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + + #[test] + fn make_registry_membership() { + let reg = make_registry(&json!([{"pubkey": RECEIPT_PUBKEY, "key_id": KEY_ID}])); + assert_eq!(reg.len(), 1); + assert!(reg.has(RECEIPT_PUBKEY)); + assert!(reg.has(&RECEIPT_PUBKEY.to_uppercase())); // case-insensitive + assert!(!reg.has(OTHER)); + assert_eq!( + make_registry(&json!({"keys":[RECEIPT_PUBKEY,"nothex!!","abc"]})).len(), + 1 + ); + assert!(make_registry(&json!([])).is_empty()); + } + + #[test] + fn trusted_signer_tristate() { + let signed = json!({"alg":"ed25519","pubkey":RECEIPT_PUBKEY,"key_id":KEY_ID,"sig":"ab"}); + let reg = make_registry(&json!([RECEIPT_PUBKEY])); + assert_eq!(trusted_signer(&signed, ®), Some(true)); // ours + assert_eq!( + trusted_signer(&signed, &make_registry(&json!([OTHER]))), + Some(false) + ); // valid shape, not trusted + assert_eq!(trusted_signer(&signed, &make_registry(&json!([]))), None); // empty + assert_eq!( + trusted_signer(&with(&signed, json!({"alg":"hmac-sha256"})), ®), + None + ); // HMAC -> can't vouch + assert_eq!( + trusted_signer(&with(&signed, json!({"key_id":"deadbeefdeadbeef"})), ®), + Some(false) + ); // key_id lie + } + + #[test] + fn registry_folds_into_verify() { + let signed = with( + &fixed_receipt(), + json!({"alg":"ed25519","sig":RECEIPT_SIG,"pubkey":RECEIPT_PUBKEY}), + ); + let reg = make_registry(&json!([RECEIPT_PUBKEY])); + assert_eq!(verify_payment_receipt(&signed, None), Some(true)); // no registry -> unchanged + assert_eq!(verify_payment_receipt_trusted(&signed, ®, None), Some(true)); // valid + ours + assert_eq!( + verify_payment_receipt_trusted(&signed, &make_registry(&json!([OTHER])), None), + Some(false) + ); // valid sig, untrusted key + assert_eq!( + verify_payment_receipt_trusted( + &with(&signed, json!({"amount_atomic":"999999"})), + ®, + None + ), + Some(false) + ); // registry never upgrades a bad sig + } +} diff --git a/threat-model.md b/threat-model.md index 263b6f2..17259ff 100644 --- a/threat-model.md +++ b/threat-model.md @@ -1,4 +1,4 @@ -# wave Dispatch — threat model (#102) +# WAVE Dispatch — threat model (#102) Adversarial review of the public surface (`dispatch.wave.online` + the local stack). ✅ = mitigated & verified, ⚠️ = partial, ☐ = open. The control-plane/data-plane split means the customer's keys, data,