diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d59d23c..59e4000 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,9 +61,39 @@ jobs: working-directory: spec run: npx --yes cddl@0.21.1 validate protocol.cddl + conformance-verify: + name: Conformance Verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Install conformance package + working-directory: conformance + run: npm ci + + - name: Confirm generate.mjs's output matches the committed vector files + working-directory: conformance + run: | + cp handshake.v1.json /tmp/handshake-committed.json + cp tokens.v1.json /tmp/tokens-committed.json + cp frames.v1.json /tmp/frames-committed.json + npm run generate + if ! diff -u /tmp/handshake-committed.json handshake.v1.json || ! diff -u /tmp/tokens-committed.json tokens.v1.json || ! diff -u /tmp/frames-committed.json frames.v1.json; then + echo "::error::conformance/*.v1.json is out of date. Run 'npm run generate' in conformance/ and commit the result -- never edit the vector files directly." + exit 1 + fi + + - name: Verify every vector round-trips through cbor2 + working-directory: conformance + run: npm run verify + required-checks: name: Required Checks - needs: [cddl-validate] + needs: [cddl-validate, conformance-verify] if: always() runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/README.md b/README.md index 4176e24..77e1c53 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ ts/ ## Implementations -None yet. The schema exists (`spec/protocol.cddl`); `rust/` and `ts/packages/core` don't exist as code, only as the structure above. Once they do: +None yet. The schema exists (`spec/protocol.cddl`), and so does `conformance/`'s golden test vector suite; `rust/` and `ts/packages/core` don't exist as code, only as the structure above. Once they do: - **[Cascade](https://github.com/Mearman/cascade)** refactors its own hand-written protocol code onto `rust/` as an ordinary Cargo dependency, rather than maintaining a parallel implementation. - **[agent-comms](https://github.com/ExaDev/agent-comms)** refactors its own wire-protocol and transport code onto `ts/packages/core` as an ordinary pnpm dependency, the same way. diff --git a/conformance/README.md b/conformance/README.md new file mode 100644 index 0000000..4f0dea6 --- /dev/null +++ b/conformance/README.md @@ -0,0 +1,23 @@ +# conformance/ + +Golden test vectors: every implementation's CI must decode each vector's `wire_hex` to its `message` and re-encode `message` back to exactly `wire_hex`. This is the actual forcing function against drift between implementations -- a schema alone never proves interop, only shared vectors do, the same lesson Cascade's own `docs/conformance/*.v1.json` was built to enforce for its XDR-based protocol. + +`handshake.v1.json` covers `handshake-frame`. `tokens.v1.json` covers `capability-token` (including a delegation chain, one token's `parent` pointing at another) and `handle-record`. `frames.v1.json` covers every other `$frame-variant` in `spec/frame.cddl`. + +## Regenerating + +``` +npm install +npm run generate # writes {handshake,tokens,frames}.v1.json from generate.mjs's vector definitions +npm run verify # decodes every committed vector and confirms it round-trips +``` + +`generate.mjs` is the actual source of truth, not the JSON files: every vector's `message` is authored as plain JS data matching a CDDL rule's fields, and `wire_hex` is derived mechanically by canonically CBOR-encoding it via [`cbor2`](https://www.npmjs.com/package/cbor2)'s CDE (CBOR Common Deterministic Encoding) mode -- the RFC 8949 4.2 core deterministic rules DAG-CBOR itself builds on -- never hand-typed. CI regenerates and diffs against the committed files the same way `spec/`'s own `cddl-validate` job does for `protocol.cddl`, so the two can never silently drift apart. + +`codec.mjs` defines the one JSON convention every vector's `message` needs: since JSON has no byte-string type, a CDDL `bstr` field is written as `{ "hex": "" }` rather than a raw string or number array. `toWire`/`fromWire` convert between that marker shape and the real bytes CBOR needs on the way in and out. + +Signature and public-key bytes throughout are clearly-synthetic filler (`aa`/`bb`/`ee`/`ff`-repeated hex), not real cryptographic material -- these vectors freeze the wire-exact envelope shape (map key ordering, field presence, the recursive delegation-chain nesting), not a working signature, the same scope Cascade's own frozen vectors commit to for fields with no real crypto behind them. + +## Gotcha: `cbor2` doesn't recognise a Node `Buffer` as a byte string + +Feeding a plain Node `Buffer` (rather than a plain `Uint8Array`) into `cbor2`'s `encode()` silently produces the wrong output: `Buffer` overrides `toJSON()`, and `cbor2`'s type dispatch falls through to a generic-object encoder that serialises it as a garbled `{ type: "Buffer", data: [...] }` CBOR map instead of a byte string, with no error raised. Confirmed directly while writing this generator -- caught only because the verifier's round-trip check failed with an unreadable diff. `toWire()` in `codec.mjs` guards against this explicitly, converting every marker to a genuine `Uint8Array` via `Uint8Array.from(Buffer.from(hex, "hex"))` rather than passing a `Buffer` straight to `encode()`. diff --git a/conformance/codec.mjs b/conformance/codec.mjs new file mode 100644 index 0000000..e90f799 --- /dev/null +++ b/conformance/codec.mjs @@ -0,0 +1,53 @@ +// Shared JSON<->wire helpers for the conformance vector generator and verifier. +// +// JSON has no byte-string type, so every CDDL `bstr` field is represented in a vector's `message` as `{ "hex": "" }` rather than a raw string or array of numbers -- this keeps `message` valid, diffable JSON while still letting the codec reconstruct exactly the bytes CBOR needs. `toWire` walks a `message` value replacing every such marker with a real byte buffer before encoding; `fromWire` walks a decoded value the other way, turning every real byte string back into the same marker shape so it can be compared against the original `message` with a plain deep-equal. + +export function hex(value) { + return { hex: value.toLowerCase() }; +} + +export function isHexMarker(value) { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).length === 1 && + typeof value.hex === "string" + ); +} + +export function toWire(value) { + if (isHexMarker(value)) { + // A plain Uint8Array, not a Node Buffer: cbor2's encoder dispatches on the exact constructor and doesn't recognise Buffer as a byte string, falling back to Buffer's own toJSON() and encoding it as a garbled {type, data} map instead -- confirmed directly, not a hypothetical. + return Uint8Array.from(Buffer.from(value.hex, "hex")); + } + if (Array.isArray(value)) { + return value.map(toWire); + } + if (value !== null && typeof value === "object") { + const out = {}; + for (const [k, v] of Object.entries(value)) out[k] = toWire(v); + return out; + } + return value; +} + +export function fromWire(value) { + if (value instanceof Uint8Array) { + return hex(Buffer.from(value).toString("hex")); + } + if (Array.isArray(value)) { + return value.map(fromWire); + } + if (value instanceof Map) { + const out = {}; + for (const [k, v] of value.entries()) out[String(k)] = fromWire(v); + return out; + } + if (value !== null && typeof value === "object") { + const out = {}; + for (const [k, v] of Object.entries(value)) out[k] = fromWire(v); + return out; + } + return value; +} diff --git a/conformance/frames.v1.json b/conformance/frames.v1.json new file mode 100644 index 0000000..a45fd60 --- /dev/null +++ b/conformance/frames.v1.json @@ -0,0 +1,361 @@ +{ + "protocol_version": 1, + "description": "Frame conformance vectors for protocol version 1, covering every $frame-variant in spec/frame.cddl except handshake-frame (see handshake.v1.json). manage-response-frame gets two vectors, one per branch of its manage-ok / manage-error outcome union.", + "vectors": [ + { + "name": "ping_v1", + "message": { + "type": "ping" + }, + "wire_hex": "a164747970656470696e67" + }, + { + "name": "close_v1_with_reason", + "message": { + "type": "close", + "reason": "shutting down" + }, + "wire_hex": "a2647479706565636c6f736566726561736f6e6d7368757474696e6720646f776e" + }, + { + "name": "gossip_v1_two_peers", + "message": { + "type": "gossip", + "peers": [ + { + "device": { + "hex": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "addresses": [ + "203.0.113.5:4433" + ], + "snapshot-seconds": 1861833600 + }, + { + "device": { + "hex": "2222222222222222222222222222222222222222222222222222222222222222" + }, + "addresses": [ + "203.0.113.9:4433", + "198.51.100.2:4433" + ], + "snapshot-seconds": 1861833601 + } + ] + }, + "wire_hex": "a2647479706566676f7373697065706565727382a366646576696365582011111111111111111111111111111111111111111111111111111111111111116961646472657373657381703230332e302e3131332e353a3434333370736e617073686f742d7365636f6e64731a6ef95380a366646576696365582022222222222222222222222222222222222222222222222222222222222222226961646472657373657382703230332e302e3131332e393a34343333713139382e35312e3130302e323a3434333370736e617073686f742d7365636f6e64731a6ef95381" + }, + { + "name": "candidates_v1_host_and_relayed", + "message": { + "type": "candidates", + "candidates": [ + { + "address": "203.0.113.5:4433", + "kind": "host", + "priority": 100 + }, + { + "address": "198.51.100.2:7000", + "kind": "relayed", + "priority": 10 + } + ] + }, + "wire_hex": "a264747970656a63616e646964617465736a63616e6469646174657382a3646b696e6464686f73746761646472657373703230332e302e3131332e353a34343333687072696f726974791864a3646b696e646772656c617965646761646472657373713139382e35312e3130302e323a37303030687072696f726974790a" + }, + { + "name": "sync_punch_v1", + "message": { + "type": "sync-punch", + "nonce": 42, + "deadline-unix-ms": 1861833605000 + }, + "wire_hex": "a364747970656a73796e632d70756e6368656e6f6e6365182a70646561646c696e652d756e69782d6d731b000001b17dee3f88" + }, + { + "name": "observed_address_v1", + "message": { + "type": "observed-address", + "address": "203.0.113.5:51820" + }, + "wire_hex": "a26474797065706f627365727665642d616464726573736761646472657373713230332e302e3131332e353a3531383230" + }, + { + "name": "relay_offer_v1", + "message": { + "type": "relay-offer", + "addresses": [ + "198.51.100.2:7000" + ] + }, + "wire_hex": "a264747970656b72656c61792d6f666665726961646472657373657381713139382e35312e3130302e323a37303030" + }, + { + "name": "relay_connect_v1", + "message": { + "type": "relay-connect", + "target-device": { + "hex": "3333333333333333333333333333333333333333333333333333333333333333" + } + }, + "wire_hex": "a264747970656d72656c61792d636f6e6e6563746d7461726765742d64657669636558203333333333333333333333333333333333333333333333333333333333333333" + }, + { + "name": "relay_data_v1", + "message": { + "type": "relay-data", + "payload": { + "hex": "dededededededededededededededededededededededede" + } + }, + "wire_hex": "a264747970656a72656c61792d64617461677061796c6f61645818dededededededededededededededededededededededede" + }, + { + "name": "relay_inbound_v1", + "message": { + "type": "relay-inbound", + "source-device": { + "hex": "2222222222222222222222222222222222222222222222222222222222222222" + } + }, + "wire_hex": "a264747970656d72656c61792d696e626f756e646d736f757263652d64657669636558202222222222222222222222222222222222222222222222222222222222222222" + }, + { + "name": "manage_request_v1_pty_spawn", + "message": { + "type": "manage-request", + "request-id": 1, + "command": { + "verb": "exec:pty", + "params": { + "verb": "pty.spawn", + "shell": "/bin/sh", + "argv": [], + "cwd": "/work", + "env": {}, + "cols": 80, + "rows": 24 + } + }, + "scope": { + "kind": "folder", + "path": "/work" + } + }, + "wire_hex": "a464747970656e6d616e6167652d726571756573746573636f7065a2646b696e6466666f6c6465726470617468652f776f726b67636f6d6d616e64a2647665726268657865633a70747966706172616d73a763637764652f776f726b63656e76a064617267768064636f6c73185064726f777318186476657262697074792e737061776e657368656c6c672f62696e2f73686a726571756573742d696401" + }, + { + "name": "manage_response_v1_ok", + "message": { + "type": "manage-response", + "request-id": 1, + "outcome": { + "result": "ok" + } + }, + "wire_hex": "a364747970656f6d616e6167652d726573706f6e7365676f7574636f6d65a166726573756c74626f6b6a726571756573742d696401" + }, + { + "name": "manage_response_v1_error", + "message": { + "type": "manage-response", + "request-id": 2, + "outcome": { + "result": "error", + "code": "scope-denied", + "message": "token does not authorise this path" + } + }, + "wire_hex": "a364747970656f6d616e6167652d726573706f6e7365676f7574636f6d65a364636f64656c73636f70652d64656e69656466726573756c74656572726f72676d6573736167657822746f6b656e20646f6573206e6f7420617574686f72697365207468697320706174686a726571756573742d696402" + }, + { + "name": "revocation_announce_v1_two_entries", + "message": { + "type": "revocation-announce", + "entries": [ + { + "token-id": { + "hex": "01010101010101010101010101010101" + }, + "revoked-at": 1861833700000 + }, + { + "token-id": { + "hex": "02020202020202020202020202020202" + }, + "revoked-at": 1861833701000 + } + ] + }, + "wire_hex": "a26474797065737265766f636174696f6e2d616e6e6f756e636567656e747269657382a268746f6b656e2d696450010101010101010101010101010101016a7265766f6b65642d61741b000001b17defb2a0a268746f6b656e2d696450020202020202020202020202020202026a7265766f6b65642d61741b000001b17defb688" + }, + { + "name": "stream_data_v1_stdout_chunk", + "message": { + "type": "stream-data", + "session": 7, + "seq": 3, + "channel": "stdout", + "bytes": { + "hex": "68656c6c6f0a" + } + }, + "wire_hex": "a5637365710364747970656b73747265616d2d646174616562797465734668656c6c6f0a676368616e6e656c667374646f75746773657373696f6e07" + }, + { + "name": "stream_ack_v1", + "message": { + "type": "stream-ack", + "session": 7, + "ack-seq": 3, + "window": 65536 + }, + "wire_hex": "a464747970656a73747265616d2d61636b6677696e646f771a000100006761636b2d736571036773657373696f6e07" + }, + { + "name": "stream_end_v1_exit_code", + "message": { + "type": "stream-end", + "session": 7, + "exit-code": 0 + }, + "wire_hex": "a364747970656a73747265616d2d656e646773657373696f6e0769657869742d636f646500" + }, + { + "name": "data_have_v1", + "message": { + "type": "data-have", + "peer": { + "hex": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "head-seq": 128 + }, + "wire_hex": "a3647065657258201111111111111111111111111111111111111111111111111111111111111111647479706569646174612d6861766568686561642d7365711880" + }, + { + "name": "data_request_v1", + "message": { + "type": "data-request", + "peer": { + "hex": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "from-seq": 100 + }, + "wire_hex": "a364706565725820111111111111111111111111111111111111111111111111111111111111111164747970656c646174612d726571756573746866726f6d2d7365711864" + }, + { + "name": "data_entries_v1_two_entries", + "message": { + "type": "data-entries", + "peer": { + "hex": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "from-seq": 100, + "entries": [ + { + "hex": "aabbcc" + }, + { + "hex": "ddeeff00" + } + ] + }, + "wire_hex": "a464706565725820111111111111111111111111111111111111111111111111111111111111111164747970656c646174612d656e747269657367656e74726965738243aabbcc44ddeeff006866726f6d2d7365711864" + }, + { + "name": "federation_link_request_v1", + "message": { + "type": "federation-link-request", + "local-mesh": "exadev-internal", + "local-name": "exadev", + "offered-shares": [ + { + "domain": "core/data", + "resource": { + "kind": "room", + "path": "general" + }, + "direction": "outbound" + } + ] + }, + "wire_hex": "a464747970657766656465726174696f6e2d6c696e6b2d726571756573746a6c6f63616c2d6d6573686f6578616465762d696e7465726e616c6a6c6f63616c2d6e616d65666578616465766e6f6666657265642d73686172657381a366646f6d61696e69636f72652f64617461687265736f75726365a2646b696e6464726f6f6d64706174686767656e6572616c69646972656374696f6e686f7574626f756e64" + }, + { + "name": "federation_link_accept_v1", + "message": { + "type": "federation-link-accept", + "remote-mesh": "example-partner", + "remote-name": "partner", + "accepted-shares": [ + { + "domain": "core/data", + "resource": { + "kind": "room", + "path": "general" + }, + "direction": "outbound" + } + ] + }, + "wire_hex": "a464747970657666656465726174696f6e2d6c696e6b2d6163636570746b72656d6f74652d6d6573686f6578616d706c652d706172746e65726b72656d6f74652d6e616d6567706172746e65726f61636365707465642d73686172657381a366646f6d61696e69636f72652f64617461687265736f75726365a2646b696e6464726f6f6d64706174686767656e6572616c69646972656374696f6e686f7574626f756e64" + }, + { + "name": "federation_link_reject_v1", + "message": { + "type": "federation-link-reject", + "reason": "no shared domains accepted" + }, + "wire_hex": "a264747970657666656465726174696f6e2d6c696e6b2d72656a65637466726561736f6e781a6e6f2073686172656420646f6d61696e73206163636570746564" + }, + { + "name": "federation_share_v1", + "message": { + "type": "federation-share", + "share": { + "domain": "core/data", + "resource": { + "kind": "room", + "path": "incidents" + }, + "direction": "bidirectional" + } + }, + "wire_hex": "a264747970657066656465726174696f6e2d7368617265657368617265a366646f6d61696e69636f72652f64617461687265736f75726365a2646b696e6464726f6f6d647061746869696e636964656e747369646972656374696f6e6d6269646972656374696f6e616c" + }, + { + "name": "federation_unshare_v1", + "message": { + "type": "federation-unshare", + "share": { + "domain": "core/data", + "resource": { + "kind": "room", + "path": "incidents" + }, + "direction": "bidirectional" + } + }, + "wire_hex": "a264747970657266656465726174696f6e2d756e7368617265657368617265a366646f6d61696e69636f72652f64617461687265736f75726365a2646b696e6464726f6f6d647061746869696e636964656e747369646972656374696f6e6d6269646972656374696f6e616c" + }, + { + "name": "federation_envelope_v1_wrapping_a_ping", + "message": { + "type": "federation-envelope", + "origin-mesh": "example-partner", + "origin-device": { + "hex": "3333333333333333333333333333333333333333333333333333333333333333" + }, + "resource": { + "kind": "room", + "path": "general" + }, + "inner": { + "hex": "a164747970656470696e67" + } + }, + "wire_hex": "a564747970657366656465726174696f6e2d656e76656c6f706565696e6e65724ba164747970656470696e67687265736f75726365a2646b696e6464726f6f6d64706174686767656e6572616c6b6f726967696e2d6d6573686f6578616d706c652d706172746e65726d6f726967696e2d64657669636558203333333333333333333333333333333333333333333333333333333333333333" + } + ] +} diff --git a/conformance/generate.mjs b/conformance/generate.mjs new file mode 100644 index 0000000..e564d91 --- /dev/null +++ b/conformance/generate.mjs @@ -0,0 +1,250 @@ +// Produces conformance/{handshake,tokens,frames}.v1.json from the vector definitions below. Each vector's `wire_hex` is derived mechanically by canonically CBOR-encoding `message` via cbor2's CDE (CBOR Common Deterministic Encoding) mode -- the same RFC 8949 4.2 core deterministic rules DAG-CBOR builds on -- never hand-typed. Signature and public-key bytes throughout are clearly-synthetic filler, not real cryptographic material: this file freezes the wire-exact envelope shape (map key ordering, field presence, array structure, nesting), not a working signature, the same scope Cascade's own frozen frames/handshake/tokens vectors commit to for structural fields with no real crypto behind them. +// +// Run `npm run generate` after changing anything below, then `npm run verify` (or just this script's own built-in verification pass at the end) to confirm every vector round-trips. + +import { writeFileSync } from "node:fs"; +import { encode, cdeEncodeOptions } from "cbor2"; +import { hex, toWire } from "./codec.mjs"; + +function wireHex(message) { + return Buffer.from(encode(toWire(message), cdeEncodeOptions)).toString("hex"); +} + +function vector(name, message) { + return { name, message, wire_hex: wireHex(message) }; +} + +// -- Shared synthetic identities, reused across files for a coherent story -- + +const deviceA = hex("11".repeat(32)); // issuer / coordinator +const deviceB = hex("22".repeat(32)); // bearer of the root token / delegator +const deviceC = hex("33".repeat(32)); // bearer of the delegated token +const deviceD = hex("44".repeat(32)); // handle-record subject + +const publicKeyEs256A = hex("04" + "aa".repeat(32) + "bb".repeat(32)); // uncompressed P-256 point, synthetic +const publicKeyEs256B = hex("04" + "cc".repeat(32) + "dd".repeat(32)); +const publicKeyEd25519D = hex("ee".repeat(32)); + +const signatureFiller = hex("ff".repeat(64)); // synthetic ES256/EdDSA-shaped signature + +// ----------------------------------------------------------------------- +// handshake.v1.json +// ----------------------------------------------------------------------- + +const handshakeVectors = [ + vector("handshake_v1_management_exec_federation", { + type: "handshake", + version: 1, + domains: ["core/management", "core/exec", "core/federation"], + }), + vector("handshake_v1_with_forward_compatible_params", { + type: "handshake", + version: 1, + domains: ["core/data"], + params: { "max-frame-size": 65536 }, + }), +]; + +// ----------------------------------------------------------------------- +// tokens.v1.json +// ----------------------------------------------------------------------- + +const rootTokenClaims = { + "token-id": hex("01".repeat(16)), + issuer: deviceA, + "issuer-key": { alg: -7, "public-key": publicKeyEs256A }, + bearer: deviceB, + capability: "exec:pty", + scope: { kind: "folder", path: "/work" }, + expires: 1893456000000, +}; + +const rootToken = [ + hex(wireHex({ 1: -7, 4: deviceA })), // protected header, {alg: -7, kid: deviceA} -- see note below on int-keyed map JSON + {}, + hex(wireHex(rootTokenClaims)), + signatureFiller, +]; + +// The protected header above is the one place this file needs a genuinely int-keyed CBOR map (cose-token-headers' cose-header-alg/-kid labels), which JSON can't represent directly as `{1: -7, 4: ...}` -- object keys are always strings in JSON. `wireHex` is given a plain JS object with numeric keys here purely to drive cbor2's encoder (cbor2 uses `Reflect.ownKeys` order, and JS coerces integer-like keys to strings internally regardless, but CDE's canonical map-key comparison is on the *encoded* key bytes, not the JS type, so `{1: -7, 4: ...}` still produces the correct integer-keyed CBOR map). This one nested map is therefore computed directly rather than round-tripped through the hex-marker convention, since it's never itself a top-level `message` value being compared. + +const rootTokenVector = vector("capability_token_v1_root_grant", rootToken); + +const delegatedTokenClaims = { + "token-id": hex("02".repeat(16)), + issuer: deviceB, + "issuer-key": { alg: -7, "public-key": publicKeyEs256B }, + bearer: deviceC, + capability: "exec:pty", + scope: { kind: "folder", path: "/work/subdir" }, + expires: 1861920000000, // earlier than the parent's expiry -- delegation narrows, never widens + parent: hex(rootTokenVector.wire_hex), +}; + +const delegatedToken = [ + hex(wireHex({ 1: -7, 4: deviceB })), + {}, + hex(wireHex(delegatedTokenClaims)), + signatureFiller, +]; + +const delegatedTokenVector = vector("capability_token_v1_delegated_narrowed_scope", delegatedToken); + +const handleClaims = { + handle: "alice@example.com", + "device-id": deviceD, + "identity-key": { alg: -8, "public-key": publicKeyEd25519D }, + candidates: [{ address: "203.0.113.5:4433", kind: "host", priority: 100 }], + issued: 1861833600000, + expires: 1861920000000, +}; + +const handleRecordVector = vector("handle_record_v1_dns_anchored", [ + hex(wireHex({ 1: -8 })), + {}, + hex(wireHex(handleClaims)), + signatureFiller, +]); + +const tokenVectors = [rootTokenVector, delegatedTokenVector, handleRecordVector]; + +// ----------------------------------------------------------------------- +// frames.v1.json -- every $frame-variant in spec/frame.cddl except handshake-frame, which lives in handshake.v1.json above. +// ----------------------------------------------------------------------- + +const innerPingFrame = { type: "ping" }; + +const frameVectors = [ + vector("ping_v1", { type: "ping" }), + vector("close_v1_with_reason", { type: "close", reason: "shutting down" }), + vector("gossip_v1_two_peers", { + type: "gossip", + peers: [ + { device: deviceA, addresses: ["203.0.113.5:4433"], "snapshot-seconds": 1861833600 }, + { device: deviceB, addresses: ["203.0.113.9:4433", "198.51.100.2:4433"], "snapshot-seconds": 1861833601 }, + ], + }), + vector("candidates_v1_host_and_relayed", { + type: "candidates", + candidates: [ + { address: "203.0.113.5:4433", kind: "host", priority: 100 }, + { address: "198.51.100.2:7000", kind: "relayed", priority: 10 }, + ], + }), + vector("sync_punch_v1", { type: "sync-punch", nonce: 42, "deadline-unix-ms": 1861833605000 }), + vector("observed_address_v1", { type: "observed-address", address: "203.0.113.5:51820" }), + vector("relay_offer_v1", { type: "relay-offer", addresses: ["198.51.100.2:7000"] }), + vector("relay_connect_v1", { type: "relay-connect", "target-device": deviceC }), + vector("relay_data_v1", { type: "relay-data", payload: hex("de".repeat(24)) }), + vector("relay_inbound_v1", { type: "relay-inbound", "source-device": deviceB }), + vector("manage_request_v1_pty_spawn", { + type: "manage-request", + "request-id": 1, + command: { + verb: "exec:pty", + params: { + verb: "pty.spawn", + shell: "/bin/sh", + argv: [], + cwd: "/work", + env: {}, + cols: 80, + rows: 24, + }, + }, + scope: { kind: "folder", path: "/work" }, + }), + vector("manage_response_v1_ok", { + type: "manage-response", + "request-id": 1, + outcome: { result: "ok" }, + }), + vector("manage_response_v1_error", { + type: "manage-response", + "request-id": 2, + outcome: { result: "error", code: "scope-denied", message: "token does not authorise this path" }, + }), + vector("revocation_announce_v1_two_entries", { + type: "revocation-announce", + entries: [ + { "token-id": hex("01".repeat(16)), "revoked-at": 1861833700000 }, + { "token-id": hex("02".repeat(16)), "revoked-at": 1861833701000 }, + ], + }), + vector("stream_data_v1_stdout_chunk", { + type: "stream-data", + session: 7, + seq: 3, + channel: "stdout", + bytes: hex("68656c6c6f0a"), // "hello\n" + }), + vector("stream_ack_v1", { type: "stream-ack", session: 7, "ack-seq": 3, window: 65536 }), + vector("stream_end_v1_exit_code", { type: "stream-end", session: 7, "exit-code": 0 }), + vector("data_have_v1", { type: "data-have", peer: deviceA, "head-seq": 128 }), + vector("data_request_v1", { type: "data-request", peer: deviceA, "from-seq": 100 }), + vector("data_entries_v1_two_entries", { + type: "data-entries", + peer: deviceA, + "from-seq": 100, + entries: [hex("aabbcc"), hex("ddeeff00")], + }), + vector("federation_link_request_v1", { + type: "federation-link-request", + "local-mesh": "exadev-internal", + "local-name": "exadev", + "offered-shares": [{ domain: "core/data", resource: { kind: "room", path: "general" }, direction: "outbound" }], + }), + vector("federation_link_accept_v1", { + type: "federation-link-accept", + "remote-mesh": "example-partner", + "remote-name": "partner", + "accepted-shares": [{ domain: "core/data", resource: { kind: "room", path: "general" }, direction: "outbound" }], + }), + vector("federation_link_reject_v1", { + type: "federation-link-reject", + reason: "no shared domains accepted", + }), + vector("federation_share_v1", { + type: "federation-share", + share: { domain: "core/data", resource: { kind: "room", path: "incidents" }, direction: "bidirectional" }, + }), + vector("federation_unshare_v1", { + type: "federation-unshare", + share: { domain: "core/data", resource: { kind: "room", path: "incidents" }, direction: "bidirectional" }, + }), + vector("federation_envelope_v1_wrapping_a_ping", { + type: "federation-envelope", + "origin-mesh": "example-partner", + "origin-device": deviceC, + resource: { kind: "room", path: "general" }, + inner: hex(wireHex(innerPingFrame)), + }), +]; + +// ----------------------------------------------------------------------- +// Write files +// ----------------------------------------------------------------------- + +function write(filename, description, vectors) { + const content = { protocol_version: 1, description, vectors }; + writeFileSync(new URL(filename, import.meta.url), JSON.stringify(content, null, 2) + "\n"); + console.log(`wrote ${filename} (${vectors.length} vectors)`); +} + +write( + "handshake.v1.json", + "Handshake conformance vectors for protocol version 1. A conformant codec must decode each wire_hex to the described message and re-encode that message to exactly wire_hex, using RFC 8949 4.2 core deterministic (DAG-CBOR-compatible) encoding.", + handshakeVectors, +); + +write( + "tokens.v1.json", + "Capability-token and handle-record conformance vectors for protocol version 1. Every cose-sign1 array's protected/payload byte strings are themselves canonical CBOR, decoded and re-verified the same way as any other bstr field. Signature and public-key bytes are structural placeholders (clearly-synthetic filler), not real cryptographic material -- this file freezes the byte-exact envelope shape (map key ordering, field presence, the recursive parent delegation chain), not a working signature, the same scope Cascade's own frozen vectors commit to for fields with no real crypto behind them yet.", + tokenVectors, +); + +write( + "frames.v1.json", + "Frame conformance vectors for protocol version 1, covering every $frame-variant in spec/frame.cddl except handshake-frame (see handshake.v1.json). manage-response-frame gets two vectors, one per branch of its manage-ok / manage-error outcome union.", + frameVectors, +); diff --git a/conformance/handshake.v1.json b/conformance/handshake.v1.json new file mode 100644 index 0000000..273045f --- /dev/null +++ b/conformance/handshake.v1.json @@ -0,0 +1,33 @@ +{ + "protocol_version": 1, + "description": "Handshake conformance vectors for protocol version 1. A conformant codec must decode each wire_hex to the described message and re-encode that message to exactly wire_hex, using RFC 8949 4.2 core deterministic (DAG-CBOR-compatible) encoding.", + "vectors": [ + { + "name": "handshake_v1_management_exec_federation", + "message": { + "type": "handshake", + "version": 1, + "domains": [ + "core/management", + "core/exec", + "core/federation" + ] + }, + "wire_hex": "a364747970656968616e647368616b6567646f6d61696e73836f636f72652f6d616e6167656d656e7469636f72652f657865636f636f72652f66656465726174696f6e6776657273696f6e01" + }, + { + "name": "handshake_v1_with_forward_compatible_params", + "message": { + "type": "handshake", + "version": 1, + "domains": [ + "core/data" + ], + "params": { + "max-frame-size": 65536 + } + }, + "wire_hex": "a464747970656968616e647368616b6566706172616d73a16e6d61782d6672616d652d73697a651a0001000067646f6d61696e738169636f72652f646174616776657273696f6e01" + } + ] +} diff --git a/conformance/package-lock.json b/conformance/package-lock.json new file mode 100644 index 0000000..ad2d8e2 --- /dev/null +++ b/conformance/package-lock.json @@ -0,0 +1,34 @@ +{ + "name": "@exadev/wire-mesh-conformance", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@exadev/wire-mesh-conformance", + "dependencies": { + "cbor2": "^2.3.0" + } + }, + "node_modules/@cto.af/wtf8": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@cto.af/wtf8/-/wtf8-0.0.5.tgz", + "integrity": "sha512-LfUFi+Vv4eDzj+XAtR89e3wwjXA/NZjUSwU5NhwbBrLecxPaBYFy3exCuc1j+D4UZeOVdqlsl8G7LmOt18V0tg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/cbor2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cbor2/-/cbor2-2.3.0.tgz", + "integrity": "sha512-76WB3hq8BoaGkMkBVJ27fW5LJU+qqDLEpgRNCG/SYKhODWXpVPOTD4UcUto3IEzYLA52nsvbhb0wabhHDn3qXg==", + "license": "MIT", + "dependencies": { + "@cto.af/wtf8": "0.0.5" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/conformance/package.json b/conformance/package.json new file mode 100644 index 0000000..323ac22 --- /dev/null +++ b/conformance/package.json @@ -0,0 +1,12 @@ +{ + "name": "@exadev/wire-mesh-conformance", + "private": true, + "type": "module", + "scripts": { + "generate": "node generate.mjs", + "verify": "node verify.mjs" + }, + "dependencies": { + "cbor2": "^2.3.0" + } +} diff --git a/conformance/tokens.v1.json b/conformance/tokens.v1.json new file mode 100644 index 0000000..0cf5067 --- /dev/null +++ b/conformance/tokens.v1.json @@ -0,0 +1,54 @@ +{ + "protocol_version": 1, + "description": "Capability-token and handle-record conformance vectors for protocol version 1. Every cose-sign1 array's protected/payload byte strings are themselves canonical CBOR, decoded and re-verified the same way as any other bstr field. Signature and public-key bytes are structural placeholders (clearly-synthetic filler), not real cryptographic material -- this file freezes the byte-exact envelope shape (map key ordering, field presence, the recursive parent delegation chain), not a working signature, the same scope Cascade's own frozen vectors commit to for fields with no real crypto behind them yet.", + "vectors": [ + { + "name": "capability_token_v1_root_grant", + "message": [ + { + "hex": "a2613126613458201111111111111111111111111111111111111111111111111111111111111111" + }, + {}, + { + "hex": "a76573636f7065a2646b696e6466666f6c6465726470617468652f776f726b6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450010101010101010101010101010101016a6361706162696c69747968657865633a7074796a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ], + "wire_hex": "845828a2613126613458201111111111111111111111111111111111111111111111111111111111111111a059010fa76573636f7065a2646b696e6466666f6c6465726470617468652f776f726b6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450010101010101010101010101010101016a6361706162696c69747968657865633a7074796a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb5840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "name": "capability_token_v1_delegated_narrowed_scope", + "message": [ + { + "hex": "a2613126613458202222222222222222222222222222222222222222222222222222222222222222" + }, + {}, + { + "hex": "a86573636f7065a2646b696e6466666f6c64657264706174686c2f776f726b2f7375626469726662656172657258203333333333333333333333333333333333333333333333333333333333333333666973737565725820222222222222222222222222222222222222222222222222222222222222222266706172656e74590180845828a2613126613458201111111111111111111111111111111111111111111111111111111111111111a059010fa76573636f7065a2646b696e6466666f6c6465726470617468652f776f726b6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450010101010101010101010101010101016a6361706162696c69747968657865633a7074796a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb5840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff67657870697265731b000001b18314880068746f6b656e2d696450020202020202020202020202020202026a6361706162696c69747968657865633a7074796a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + }, + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ], + "wire_hex": "845828a2613126613458202222222222222222222222222222222222222222222222222222222222222222a05902a0a86573636f7065a2646b696e6466666f6c64657264706174686c2f776f726b2f7375626469726662656172657258203333333333333333333333333333333333333333333333333333333333333333666973737565725820222222222222222222222222222222222222222222222222222222222222222266706172656e74590180845828a2613126613458201111111111111111111111111111111111111111111111111111111111111111a059010fa76573636f7065a2646b696e6466666f6c6465726470617468652f776f726b6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450010101010101010101010101010101016a6361706162696c69747968657865633a7074796a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb5840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff67657870697265731b000001b18314880068746f6b656e2d696450020202020202020202020202020202026a6361706162696c69747968657865633a7074796a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd5840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "name": "handle_record_v1_dns_anchored", + "message": [ + { + "hex": "a1613127" + }, + {}, + { + "hex": "a66668616e646c6571616c696365406578616d706c652e636f6d666973737565641b000001b17dee2c0067657870697265731b000001b183148800696465766963652d6964582044444444444444444444444444444444444444444444444444444444444444446a63616e6469646174657381a3646b696e6464686f73746761646472657373703230332e302e3131332e353a34343333687072696f7269747918646c6964656e746974792d6b6579a263616c67276a7075626c69632d6b65795820eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ], + "wire_hex": "8444a1613127a058e2a66668616e646c6571616c696365406578616d706c652e636f6d666973737565641b000001b17dee2c0067657870697265731b000001b183148800696465766963652d6964582044444444444444444444444444444444444444444444444444444444444444446a63616e6469646174657381a3646b696e6464686f73746761646472657373703230332e302e3131332e353a34343333687072696f7269747918646c6964656e746974792d6b6579a263616c67276a7075626c69632d6b65795820eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee5840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ] +} diff --git a/conformance/verify.mjs b/conformance/verify.mjs new file mode 100644 index 0000000..b8c4bc2 --- /dev/null +++ b/conformance/verify.mjs @@ -0,0 +1,39 @@ +// Reads {handshake,tokens,frames}.v1.json and, for every vector, confirms both directions independently: decoding wire_hex reproduces message exactly, and re-encoding message reproduces wire_hex exactly. Exits non-zero and prints every failure on any mismatch -- never skips or silently tolerates one, since a silently-wrong vector defeats the entire point of a conformance suite. + +import { readFileSync } from "node:fs"; +import assert from "node:assert/strict"; +import { encode, decode, cdeEncodeOptions, cdeDecodeOptions } from "cbor2"; +import { fromWire, toWire } from "./codec.mjs"; + +const files = ["handshake.v1.json", "tokens.v1.json", "frames.v1.json"]; + +let total = 0; +let failed = 0; + +for (const filename of files) { + const url = new URL(filename, import.meta.url); + const { vectors } = JSON.parse(readFileSync(url, "utf8")); + + for (const { name, message, wire_hex } of vectors) { + total += 1; + try { + const encoded = Buffer.from(encode(toWire(message), cdeEncodeOptions)).toString("hex"); + assert.strictEqual(encoded, wire_hex, "re-encoding message did not reproduce wire_hex"); + + const decoded = fromWire(decode(Buffer.from(wire_hex, "hex"), cdeDecodeOptions)); + assert.deepStrictEqual(decoded, message, "decoding wire_hex did not reproduce message"); + + console.log(` ok ${filename} :: ${name}`); + } catch (err) { + failed += 1; + console.error(`FAIL ${filename} :: ${name}`); + console.error(` ${err.message}`); + } + } +} + +console.log(`\n${total - failed}/${total} vectors verified`); + +if (failed > 0) { + process.exit(1); +} diff --git a/justfile b/justfile index 5e44df9..548b325 100644 --- a/justfile +++ b/justfile @@ -1,10 +1,11 @@ # Thin task dispatcher across the two implementation subtrees. Each recipe # just cd's into its own subtree and calls that language's native tool -- # this file has no build-graph or caching logic of its own, and isn't meant -# to. rust/ and ts/ don't exist as code yet (only spec/ does), so most -# recipes are no-ops until they do -- the point right now is that the -# dispatcher itself exists and matches what the README already describes, -# not that there's real work to dispatch to yet. +# to. rust/ and ts/ don't exist as code yet (only spec/ and conformance/ do), +# so build/test/lint recipes stay no-ops until they do -- the point right +# now is that the dispatcher itself exists and matches what the README +# already describes, not that there's real work for those specific +# recipes to dispatch to yet. default: @just --list @@ -29,7 +30,11 @@ spec: cd spec && ./generate.sh cd spec && npx --yes cddl@0.21.1 validate protocol.cddl -# Run both implementations against spec/conformance/'s golden vectors, once they exist. +# Regenerate conformance/'s golden vectors and verify every one round-trips +# through cbor2. Once rust/ and ts/ exist, each implementation's own +# conformance-check additionally runs against these same vector files. conformance: + cd conformance && npm run generate + cd conformance && npm run verify @if [ -d rust ]; then cd rust && cargo run --bin conformance-check; else echo "rust/ does not exist yet"; fi @if [ -d ts ]; then cd ts && pnpm conformance-check; else echo "ts/ does not exist yet"; fi diff --git a/spec/README.md b/spec/README.md index 94d5b2c..d22c1d7 100644 --- a/spec/README.md +++ b/spec/README.md @@ -12,6 +12,8 @@ Two sockets (`$frame-variant`, `$manage-command-params`) are the deliberate exte `registry/` holds the append-only core domain and capability name lists referenced from `handshake.cddl` and `tokens.cddl`. +A schema alone never proves interop, only shared vectors do -- see `../conformance/` for the golden test vectors every implementation's CI round-trips against, one per structure defined here. + ## Validated against two independent RFC 8610 implementations CI runs `protocol.cddl` through the [`cddl`](https://www.npmjs.com/package/cddl) npm package. It has also been checked directly against the [`cddl`](https://crates.io/crates/cddl) Rust crate (`cargo install cddl`, then `cddl compile-cddl --cddl protocol.cddl`), which reported it fully conformant with no issues. The two disagreed once during authoring — the npm parser rejected bare integer map keys and an inline type-choice used directly as a map key, both valid per RFC 8610's grammar — which is why `tokens.cddl`'s COSE header labels are named rules (`cose-header-alg`, `cose-header-kid`) and `cose-header-label` rather than inline literals; the fix for the stricter parser turned out to already satisfy the Rust crate too, so nothing needed reconciling once both were checked.