Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 3 additions & 3 deletions conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Golden test vectors: every implementation's CI must decode each vector's `wire_h

`adverts.v1.json` is the one file built on real cryptographic material rather than structural filler, and the one whose vectors carry more than a round trip. A peer-advert's signature is the only thing binding a gossiped device-id to the device that owns it (`spec/transport.cddl`), so each vector also records `signing_input_hex`, the exact bytes that signature covers, and `verifies`, the verdict a conformant verifier must reach. Two implementations that reconstruct the signing input even one byte differently reject each other's every advert while each passes its own tests, which is precisely the failure a frozen byte string turns into a failing vector. Ed25519 rather than ES256, from a fixed seed, because its signatures are deterministic (RFC 8032) and CI regenerates this file and diffs it against the committed copy.

`verify.test.ts` checks only the round trip every vector file shares: this package holds no protocol implementation of its own, so `signing_input_hex` and `verifies` are checked by the implementations themselves, in `ts/packages/core`'s own conformance test and in `rust/crates/wire-mesh-conformance`.
`verify.unit.test.ts` checks only the round trip every vector file shares: this package holds no protocol implementation of its own, so `signing_input_hex` and `verifies` are checked by the implementations themselves, in `ts/packages/core`'s own conformance test and in `rust/crates/wire-mesh-conformance`.

## Regenerating

Expand All @@ -28,9 +28,9 @@ Linting uses [`@exadev/eslint-config`](https://www.npmjs.com/package/@exadev/esl

`codec.ts` 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": "<lowercase 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; `isVectorFile` validates a parsed vector file at the JSON boundary rather than trusting an `as` cast.

`generate.ts` and `verify.test.ts` both import it as `@exadev/wire-mesh-conformance` (self-referencing the package by its own name), not via a relative path -- `pnpm run build` (`tsdown`) compiles `codec.ts` into dual ESM/CJS output plus `.d.mts`/`.d.cts` declarations under `dist/`, and `package.json`'s `exports` map is what makes the self-reference resolve to that built output rather than the source file. This means the same artifact every consumer would actually get is what runs here, not a stand-in. [`@arethetypeswrong/cli`](https://github.com/arethetypeswrong/arethetypeswrong.github.io) (wired into the `build` script via `tsdown`'s own `attw` option) checks that dual-package surface resolves correctly under Node's `node16` module resolution -- catching the class of "works in this repo, broken for a real consumer" bug that a bare `tsc` build can't see, before it ever has to matter.
`generate.ts` and `verify.unit.test.ts` both import it as `@exadev/wire-mesh-conformance` (self-referencing the package by its own name), not via a relative path -- `pnpm run build` (`tsdown`) compiles `codec.ts` into dual ESM/CJS output plus `.d.mts`/`.d.cts` declarations under `dist/`, and `package.json`'s `exports` map is what makes the self-reference resolve to that built output rather than the source file. This means the same artifact every consumer would actually get is what runs here, not a stand-in. [`@arethetypeswrong/cli`](https://github.com/arethetypeswrong/arethetypeswrong.github.io) (wired into the `build` script via `tsdown`'s own `attw` option) checks that dual-package surface resolves correctly under Node's `node16` module resolution -- catching the class of "works in this repo, broken for a real consumer" bug that a bare `tsc` build can't see, before it ever has to matter.

Node (26+) runs every `.ts` file here directly via its own native TypeScript support -- no `tsx`/`ts-node` needed. `tsconfig.json` sets `moduleResolution: "nodenext"` to match that reality, and is scoped to `codec.ts` alone (`"include": ["codec.ts"]`) since that's the one file `tsdown` actually builds -- anything else in this scope (a script's own top-level await, a config file's own type shape) would otherwise leak into what gets type-checked as part of the *build*. Everything else (`generate.ts`, `verify.test.ts`, `tsdown.config.ts`, `eslint.config.ts`) is covered by `tsconfig.node.json` instead, which extends `tsconfig.json`. `pnpm run typecheck` runs both.
Node (26+) runs every `.ts` file here directly via its own native TypeScript support -- no `tsx`/`ts-node` needed. `tsconfig.json` sets `moduleResolution: "nodenext"` to match that reality, and is scoped to `codec.ts` alone (`"include": ["codec.ts"]`) since that's the one file `tsdown` actually builds -- anything else in this scope (a script's own top-level await, a config file's own type shape) would otherwise leak into what gets type-checked as part of the *build*. Everything else (`generate.ts`, `verify.unit.test.ts`, `tsdown.config.ts`, `eslint.config.ts`) is covered by `tsconfig.node.json` instead, which extends `tsconfig.json`. `pnpm run typecheck` runs both.

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.

Expand Down
44 changes: 25 additions & 19 deletions conformance/adverts.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// adverts.v1.json's own vectors, kept out of generate.ts because they are the one set here built on real cryptographic material rather than structural filler, and because generate.ts is already at this repo's own max-lines cap.
//
// A peer-advert's signature is the only thing tying a gossiped device-id to the device that owns it (spec/transport.cddl, wire-mesh#225), and two implementations that disagree by a single byte about what is signed will reject each other's every advert while each passes its own tests. Freezing the signing input alongside the wire bytes is what turns that disagreement into a failing vector rather than a field mystery. The construction below is written from the spec text, deliberately not imported from either implementation: a vector generated by the thing it is meant to check would prove nothing.
//
// Ed25519 rather than ES256 because its signatures are deterministic (RFC 8032): CI regenerates this file's output and diffs it against the committed copy, which an ECDSA signature's fresh per-signing nonce would fail on every run. The keys come from fixed seeds for the same reason.
/* adverts.v1.json's own vectors, kept out of generate.ts because they are the one set here built on real cryptographic material rather than structural filler, and because generate.ts is already at this repo's own max-lines cap.

A peer-advert's signature is the only thing tying a gossiped device-id to the device that owns it (spec/transport.cddl, wire-mesh#225), and two implementations that disagree by a single byte about what is signed will reject each other's every advert while each passes its own tests. Freezing the signing input alongside the wire bytes is what turns that disagreement into a failing vector rather than a field mystery. The construction below is written from the spec text, deliberately not imported from either implementation: a vector generated by the thing it is meant to check would prove nothing.

Ed25519 rather than ES256 because its signatures are deterministic (RFC 8032): CI regenerates this file's output and diffs it against the committed copy, which an ECDSA signature's fresh per-signing nonce would fail on every run. The keys come from fixed seeds for the same reason. */

import {
createHash,
Expand All @@ -20,9 +20,12 @@ import {
} from "@exadev/wire-mesh-conformance";

const ED25519_SEED_BYTE_LENGTH = 32;
const EDDSA = -8; // COSE algorithm identifier for pure Ed25519 (RFC 9053)
const LOW_BYTE_MASK = 0xff; // XOR operand keeping a corrupted byte within one octet
const SNAPSHOT_SECONDS = 1861833600; // an arbitrary fixed Unix-seconds instant, so regenerating reproduces the committed bytes
// COSE algorithm identifier for pure Ed25519 (RFC 9053)
const EDDSA = -8;
// XOR operand keeping a corrupted byte within one octet
const LOW_BYTE_MASK = 0xff;
// an arbitrary fixed Unix-seconds instant, so regenerating reproduces the committed bytes
const SNAPSHOT_SECONDS = 1861833600;

/** RFC 8410 section 7's PKCS#8 prefix for an Ed25519 private key, sitting ahead of the 32 raw seed bytes and encoding the version, the 1.3.101.112 algorithm identifier, and the octet-string wrapper around the seed. Node accepts no rawer form for an Ed25519 private key, so the seed is wrapped rather than imported directly. */
const PKCS8_ED25519_PREFIX = Buffer.from(
Expand Down Expand Up @@ -57,6 +60,7 @@ function identityFromSeed(seedFillHex: string): AdvertIdentity {
throw new Error("Ed25519 public JWK is missing its raw x coordinate");
}
const publicKeyBytes = Buffer.from(publicJwk.x, "base64url");

return {
privateKey,
publicKeyBytes,
Expand All @@ -79,6 +83,7 @@ function advertSigningInput(
}
content[key] = value;
}

return Buffer.concat([
PEER_ADVERT_SIGNING_CONTEXT,
Buffer.from(encode(toWire(content), cdeEncodeOptions)),
Expand All @@ -92,23 +97,27 @@ function wireHex(message: JsonWire): string {
/**
* Builds one advert vector, signing the content with `signer` and recording the verdict a conformant verifier must reach for it.
*
* `tamper` runs after signing, which is how the negative vectors are produced: the advert really was signed, and then something the signature covers was changed, exactly as a hostile relay or peer would do it.
* `tamper` runs after signing, which is how the negative vectors are produced: the advert really was signed, and then something the signature covers was changed, exactly as a hostile relay or peer would do it. It returns the altered copy rather than mutating the signed advert.
*/
function advertVector(
name: string,
advert: Readonly<Record<string, JsonWire>>,
options: Readonly<{
signer: KeyObject;
verifies: boolean;
tamper?: (signed: Record<string, JsonWire>) => void;
tamper?: (
signed: Readonly<Record<string, JsonWire>>,
) => Record<string, JsonWire>;
}>,
): AdvertVector {
const signature = nodeSign(null, advertSigningInput(advert), options.signer);
const signed: Record<string, JsonWire> = {
const genuine: Record<string, JsonWire> = {
...advert,
signature: hex(signature.toString("hex")),
};
options.tamper?.(signed);
const signed =
options.tamper === undefined ? genuine : options.tamper(genuine);

return {
name,
message: signed,
Expand Down Expand Up @@ -146,18 +155,14 @@ export const advertVectors: AdvertVector[] = [
{
signer: advertiser.privateKey,
verifies: false,
tamper: (signed) => {
signed["presence/status"] = "active";
},
tamper: (signed) => ({ ...signed, "presence/status": "active" }),
},
),
// An extension key added after signing, which the tail's openness would otherwise make free: a verifier that encoded only the fields it recognised would accept this.
advertVector("peer_advert_v1_added_extension", advertBase, {
signer: advertiser.privateKey,
verifies: false,
tamper: (signed) => {
signed["room/hosted"] = ["general"];
},
tamper: (signed) => ({ ...signed, "room/hosted": ["general"] }),
}),
// A genuine signature by a real key, over an advert naming somebody else's device: refused by self-certification, since sha256 of the embedded public-key is not the claimed device.
advertVector(
Expand Down Expand Up @@ -192,7 +197,8 @@ export const advertVectors: AdvertVector[] = [
throw new Error("signature has no bytes to corrupt");
}
bytes[lastIndex] = lastByte ^ LOW_BYTE_MASK;
signed.signature = hex(bytes.toString("hex"));

return { ...signed, signature: hex(bytes.toString("hex")) };
},
}),
];
16 changes: 13 additions & 3 deletions conformance/codec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Shared JSON<->wire helpers for the conformance vector generator and its vitest suite.
//
// JSON has no byte-string type, so every CDDL `bstr` field is represented in a vector's `message` as `{ "hex": "<lowercase 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.
/* Shared JSON<->wire helpers for the conformance vector generator and its vitest suite.

JSON has no byte-string type, so every CDDL `bstr` field is represented in a vector's `message` as `{ "hex": "<lowercase 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 interface HexBytes {
hex: string;
Expand Down Expand Up @@ -54,6 +54,7 @@ function isHexBytes(value: unknown): value is HexBytes {
return false;
if (!("hex" in value)) return false;
if (Object.keys(value).length !== 1) return false;

return typeof value.hex === "string";
}

Expand All @@ -78,8 +79,10 @@ export function toWire(value: JsonWire): unknown {
if (value !== null && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) out[k] = toWire(v);

return out;
}

return value;
}

Expand All @@ -93,11 +96,13 @@ export function fromWire(value: unknown): JsonWire {
if (value instanceof Map) {
const out: Record<string, JsonWire> = {};
for (const [k, v] of value.entries()) out[String(k)] = fromWire(v);

return out;
}
if (isPlainObject(value)) {
const out: Record<string, JsonWire> = {};
for (const [k, v] of Object.entries(value)) out[k] = fromWire(v);

return out;
}
if (
Expand All @@ -124,13 +129,15 @@ function isJsonWire(value: unknown): value is JsonWire {
}
if (Array.isArray(value)) return value.every(isJsonWire);
if (typeof value === "object") return Object.values(value).every(isJsonWire);

return false;
}

function isVector(value: unknown): value is Vector {
if (typeof value !== "object" || value === null) return false;
if (!("name" in value) || !("message" in value) || !("wire_hex" in value))
return false;

return (
typeof value.name === "string" &&
isJsonWire(value.message) &&
Expand All @@ -141,12 +148,14 @@ function isVector(value: unknown): value is Vector {
export function isVectorFile(value: unknown): value is VectorFile {
if (typeof value !== "object" || value === null) return false;
if (!("vectors" in value)) return false;

return Array.isArray(value.vectors) && value.vectors.every(isVector);
}

function isAdvertVector(value: unknown): value is AdvertVector {
if (!isVector(value)) return false;
if (!("signing_input_hex" in value) || !("verifies" in value)) return false;

return (
typeof value.signing_input_hex === "string" &&
typeof value.verifies === "boolean"
Expand All @@ -156,5 +165,6 @@ function isAdvertVector(value: unknown): value is AdvertVector {
export function isAdvertVectorFile(value: unknown): value is AdvertVectorFile {
if (typeof value !== "object" || value === null) return false;
if (!("vectors" in value)) return false;

return Array.isArray(value.vectors) && value.vectors.every(isAdvertVector);
}
9 changes: 6 additions & 3 deletions conformance/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { exadevConfig } from "@exadev/eslint-config";
import { defineConfig } from "eslint/config";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
import globals from "globals";

export default exadevConfig(
{},
export default defineConfig(
...exadevConfig(),
{
ignores: ["dist", "coverage", "node_modules", ".turbo"],
// The golden vector files are written by generate.ts and CI diffs them against a fresh generation, so their key order and layout belong to the generator, not to a formatter or key-sorting rule.
ignores: ["dist", "coverage", "node_modules", ".turbo", "*.v1.json"],
},
{
languageOptions: {
Expand All @@ -17,6 +19,7 @@ export default exadevConfig(
},
},
{
files: ["**/*.ts", "**/*.mts", "**/*.cts"],
rules: {
"@typescript-eslint/consistent-type-imports": [
"error",
Expand Down
Loading
Loading