From 1e416a5c1f30c91b190e62d73dd2d5cef6b50339 Mon Sep 17 00:00:00 2001 From: Oleksii Date: Sun, 23 Aug 2026 23:45:34 -0300 Subject: [PATCH] feat(alpha): add verifiable LAN evidence kit --- .github/workflows/ci.yml | 18 +- Cargo.lock | 2 + apps/client/src-tauri/Cargo.lock | 51 ++++- apps/client/src/lib/report.test.ts | 36 +-- apps/client/src/lib/report.ts | 42 ++-- apps/client/src/routes/Match.tsx | 5 +- docs/IMPLEMENTATION_STATUS.md | 2 + docs/alpha/LAN_TEST.md | 23 +- docs/alpha/reports/README.md | 22 +- packages/networking/Cargo.toml | 2 + .../src/bin/opencade-match-probe.rs | 33 ++- .../src/bin/opencade-match-verify.rs | 107 +++++++++ packages/networking/src/lib.rs | 2 + packages/networking/src/report.rs | 208 ++++++++++++++++++ packages/networking/tests/report_verifier.rs | 107 +++++++++ .../networking/tests/two_process_probe.rs | 22 +- .../protocol/src/generated/MatchReport.ts | 15 ++ .../src/generated/MatchReportClient.ts | 6 + .../src/generated/MatchReportProbe.ts | 15 ++ .../protocol/src/generated/MatchReportRole.ts | 6 + .../protocol/src/generated/MatchReportRoom.ts | 7 + .../src/generated/MatchReportTransport.ts | 6 + packages/protocol/src/index.ts | 6 + packages/protocol/src/lib.rs | 60 +++++ packages/protocol/src/messages.ts | 10 + 25 files changed, 745 insertions(+), 68 deletions(-) create mode 100644 packages/networking/src/bin/opencade-match-verify.rs create mode 100644 packages/networking/src/report.rs create mode 100644 packages/networking/tests/report_verifier.rs create mode 100644 packages/protocol/src/generated/MatchReport.ts create mode 100644 packages/protocol/src/generated/MatchReportClient.ts create mode 100644 packages/protocol/src/generated/MatchReportProbe.ts create mode 100644 packages/protocol/src/generated/MatchReportRole.ts create mode 100644 packages/protocol/src/generated/MatchReportRoom.ts create mode 100644 packages/protocol/src/generated/MatchReportTransport.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e215ae..b9aff81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,7 +142,23 @@ jobs: - name: Cache Cargo uses: Swatinem/rust-cache@v2 with: - workspaces: apps/client/src-tauri + workspaces: | + . + apps/client/src-tauri + + - name: Build Windows LAN alpha tools + run: cargo build -p opencade-networking --bins --release --locked + + - name: Upload Windows LAN alpha tools + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@v4 + with: + name: opencade-lan-tools-windows + path: | + target/release/opencade-match-probe.exe + target/release/opencade-match-verify.exe + if-no-files-found: error + retention-days: 14 - name: Check desktop backend run: cargo check --manifest-path apps/client/src-tauri/Cargo.toml --all-targets --locked diff --git a/Cargo.lock b/Cargo.lock index 20bc95f..659fdac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -923,7 +923,9 @@ dependencies = [ name = "opencade-networking" version = "0.1.0" dependencies = [ + "chrono", "opencade-emulator-sdk", + "opencade-protocol", "serde", "serde_json", "thiserror 1.0.69", diff --git a/apps/client/src-tauri/Cargo.lock b/apps/client/src-tauri/Cargo.lock index afea69e..7a933fd 100644 --- a/apps/client/src-tauri/Cargo.lock +++ b/apps/client/src-tauri/Cargo.lock @@ -270,8 +270,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -1707,13 +1709,26 @@ dependencies = [ name = "opencade-networking" version = "0.1.0" dependencies = [ + "chrono", "opencade-emulator-sdk", + "opencade-protocol", "serde", "serde_json", "thiserror", "tokio", ] +[[package]] +name = "opencade-protocol" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "ts-rs", + "uuid", +] + [[package]] name = "pango" version = "0.15.10" @@ -2878,6 +2893,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thin-slice" version = "0.1.1" @@ -3123,6 +3147,30 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "ts-rs" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b44017f9f875786e543595076374b9ef7d13465a518dd93d6ccdbf5b432dde8c" +dependencies = [ + "chrono", + "thiserror", + "ts-rs-macros", + "uuid", +] + +[[package]] +name = "ts-rs-macros" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c88cc88fd23b5a04528f3a8436024f20010a16ec18eb23c164b1242f65860130" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "termcolor", +] + [[package]] name = "typenum" version = "1.20.1" @@ -3181,6 +3229,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" dependencies = [ "getrandom 0.2.17", + "serde", ] [[package]] @@ -3401,7 +3450,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/apps/client/src/lib/report.test.ts b/apps/client/src/lib/report.test.ts index 66917d4..db6f12a 100644 --- a/apps/client/src/lib/report.test.ts +++ b/apps/client/src/lib/report.test.ts @@ -2,7 +2,19 @@ import { describe, expect, it } from "vitest"; import { buildMatchReport } from "./report.js"; describe("buildMatchReport", () => { - it("contains room correlation without credentials or local paths", () => { + const probe = { + room_id: "room-123", + local_user_id: "host", + peer_user_id: "guest", + role: "host" as const, + transport: "direct_udp" as const, + frames_sent: 64, + frames_received: 60, + transcript_checksum: "0123456789abcdef", + elapsed_ms: 240, + }; + + it("contains room correlation without identities, credentials, or local paths", () => { const report = buildMatchReport( { id: "room-123", @@ -11,11 +23,16 @@ describe("buildMatchReport", () => { guest_id: "guest", state: "finished", }, + probe, new Date("2026-08-23T12:00:00Z") ); const serialized = JSON.stringify(report); expect(report.exported_at).toBe("2026-08-23T12:00:00.000Z"); expect(serialized).toContain("room-123"); + expect(serialized).not.toContain("local_user_id"); + expect(serialized).not.toContain("peer_user_id"); + expect(serialized).not.toContain("host_id"); + expect(serialized).not.toContain("guest_id"); expect(serialized).not.toContain("token"); expect(serialized).not.toContain("rom_path"); }); @@ -29,23 +46,14 @@ describe("buildMatchReport", () => { guest_id: "guest", state: "finished", }, - new Date("2026-08-23T12:00:00Z"), - { - room_id: "room-123", - local_user_id: "host", - peer_user_id: "guest", - role: "host", - transport: "direct_udp", - frames_sent: 60, - frames_received: 60, - transcript_checksum: "0123456789abcdef", - elapsed_ms: 240, - } + probe, + new Date("2026-08-23T12:00:00Z") ); expect(report.probe).toEqual({ + role: "host", transport: "direct_udp", - frames_sent: 60, + frames_sent: 64, frames_received: 60, transcript_checksum: "0123456789abcdef", elapsed_ms: 240, diff --git a/apps/client/src/lib/report.ts b/apps/client/src/lib/report.ts index d23f1e5..7067082 100644 --- a/apps/client/src/lib/report.ts +++ b/apps/client/src/lib/report.ts @@ -1,37 +1,23 @@ -import type { RoomPayload } from "@opencade/protocol"; +import type { MatchReport, RoomPayload } from "@opencade/protocol"; import type { MatchProbeReport } from "./native"; -export type MatchProbeEvidence = Pick< - MatchProbeReport, - "transport" | "frames_sent" | "frames_received" | "transcript_checksum" | "elapsed_ms" ->; - -export type MatchReport = { - schema_version: 1; - exported_at: string; - room: RoomPayload; - probe?: MatchProbeEvidence; - client: { platform: string; user_agent: string }; -}; - export function buildMatchReport( room: RoomPayload, - now = new Date(), - probe?: MatchProbeReport + probe: MatchProbeReport, + now = new Date() ): MatchReport { return { schema_version: 1, exported_at: now.toISOString(), - room, - ...(probe && { - probe: { - transport: probe.transport, - frames_sent: probe.frames_sent, - frames_received: probe.frames_received, - transcript_checksum: probe.transcript_checksum, - elapsed_ms: probe.elapsed_ms, - }, - }), + room: { id: room.id, game_id: room.game_id, state: room.state }, + probe: { + role: probe.role, + transport: probe.transport, + frames_sent: probe.frames_sent, + frames_received: probe.frames_received, + transcript_checksum: probe.transcript_checksum, + elapsed_ms: probe.elapsed_ms, + }, client: { platform: typeof navigator === "undefined" ? "unknown" : navigator.platform, user_agent: typeof navigator === "undefined" ? "unknown" : navigator.userAgent, @@ -39,8 +25,8 @@ export function buildMatchReport( }; } -export function downloadMatchReport(room: RoomPayload, probe?: MatchProbeReport): void { - const report = buildMatchReport(room, new Date(), probe); +export function downloadMatchReport(room: RoomPayload, probe: MatchProbeReport): void { + const report = buildMatchReport(room, probe, new Date()); const url = URL.createObjectURL( new Blob([JSON.stringify(report, null, 2)], { type: "application/json" }) ); diff --git a/apps/client/src/routes/Match.tsx b/apps/client/src/routes/Match.tsx index 6e2f86d..4bc3e78 100644 --- a/apps/client/src/routes/Match.tsx +++ b/apps/client/src/routes/Match.tsx @@ -50,6 +50,7 @@ export default function Match({ : state === "finished" ? "Match complete" : `Room ${state}`; + const completedRoom = room.data?.state === "finished" ? room.data : undefined; return (

Room {roomId.slice(0, 8)}

@@ -93,10 +94,10 @@ export default function Match({ {isResetting ? "Resetting LAN probe…" : "Retry LAN probe"} )} - {room.data && ( + {completedRoom && probeReport && ( diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 9f85e2f..682481c 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -19,6 +19,8 @@ Updated 2026-08-23. runner wired through authenticated endpoint exchange and the desktop match screen. - A standalone two-node probe CLI plus a real two-process localhost test that verifies identical 60-frame transcripts and machine-readable reports. +- One canonical, privacy-minimized desktop/CLI evidence format, a fail-closed paired-report + verifier, and CI-built Windows LAN alpha tools. - PostgreSQL, WebSocket, lifecycle, safe-launch, mock-match, UDP, two-process, TypeScript, and MSRV checks in CI. diff --git a/docs/alpha/LAN_TEST.md b/docs/alpha/LAN_TEST.md index 9af44e4..443dc15 100644 --- a/docs/alpha/LAN_TEST.md +++ b/docs/alpha/LAN_TEST.md @@ -24,9 +24,15 @@ direct UDP frame transport; it does not claim FBNeo netplay support. transcript checksum. The host then transitions the room to `playing` and `finished`. 6. If either side reports a firewall or timeout error, allow the advertised UDP port through the firewall and select `Retry LAN probe` on both clients. -7. Export the redacted report from each client and compare `room.id`, `game_id`, final state, - `probe.frames_received`, and `probe.transcript_checksum`. Probe evidence deliberately omits - nonces, peer endpoints, and local user identifiers. +7. Export the redacted report from each client. Copy both reports to one machine and verify them: + + ```bash + opencade-match-verify host-report.json guest-report.json + ``` + + A pass prints JSON with `"verified":true`; a mismatch prints a stable error code to stderr and + exits non-zero. Reports deliberately omit nonces, endpoints, user identifiers, session material, + and local paths. For transport-only diagnosis without the desktop flow, build `opencade-match-probe` and run one process on each host with complementary arguments: @@ -39,14 +45,21 @@ cargo run -p opencade-networking --bin opencade-match-probe -- \ ``` The other host swaps local/peer addresses and users and uses `--role guest` with the same room, -game, session key, frame count, and timeout. The command prints a machine-readable JSON report. +game, session key, frame count, and timeout. The command prints the same canonical, redacted JSON +format as the desktop client. Download both Windows tools from the `opencade-lan-tools-windows` +artifact on a successful `main` CI run, or build them with: + +```bash +cargo build -p opencade-networking --bins --release --locked +``` ## Pass criteria - Both clients agree on the room and users. - No non-member can mutate or signal into the room. - The UDP transcript is ordered and identical at both endpoints. -- Both reports contain the same `transcript_checksum` and `frames_received = 60`. +- `opencade-match-verify` accepts the host/guest report pair: same room, game, checksum, finished + state, opposite roles, direct UDP, and exactly 60 received frames. - The match row has `started_at` and `ended_at`. - Reports contain no session token, password, full ROM path, or emulator binary. diff --git a/docs/alpha/reports/README.md b/docs/alpha/reports/README.md index a4316dd..5d7bec7 100644 --- a/docs/alpha/reports/README.md +++ b/docs/alpha/reports/README.md @@ -1,9 +1,12 @@ # LAN Alpha Reports — Manual Gate -This directory is intentionally empty in the repository. Each physical LAN attempt produces one redacted JSON report via: +This directory is intentionally empty in the repository. Each physical LAN attempt produces a pair +of canonical schema-v1 redacted JSON reports via: -- Desktop: `Export Report` in Match screen (redacted `room_id`, `game_id`, `local/peer_user_id`, `role`, `transport:direct_udp`, `frames_received:60`, `transcript_checksum`, `room_state:FINISHED`) -- CLI probe: `cargo run -p opencade-networking --bin opencade-match-probe -- --local --peer --room --game sfiii3 --local-user host --peer-user guest --role host --session-key --frames 60 --timeout-ms 5000` (prints JSON) +- Desktop: `Export report` after the room reaches `finished`. +- CLI probe: `opencade-match-probe --local --peer --room --game sfiii3 --local-user host --peer-user guest --role host --session-key --frames 60 --timeout-ms 5000`. + +Both producers omit identities, endpoints, nonces, session material, credentials, and local paths. Local 10/10 proof (single box, loopback) is automated: @@ -13,12 +16,15 @@ for i in 1..10; do cargo test -p opencade-networking --test two_process_probe -- Result 10/10 on 2026-08-24 (see CI). -Physical LAN requires 2× Windows 10/11 on same subnet, `docker compose up --build -d`, `VITE_API_URL=http://:8080`, firewall TCP 8080 + UDP probe ports. Follow `docs/alpha/LAN_TEST.md` and save each attempt as `report-01.json` … `report-10.json` then `jq` check: +Physical LAN requires 2× Windows 10/11 on the same subnet, `docker compose up --build -d`, +`VITE_API_URL=http://:8080`, firewall TCP 8080 + UDP probe ports. Follow +`docs/alpha/LAN_TEST.md`, save each attempt as `attempt-01-host.json` and +`attempt-01-guest.json`, then verify every pair: ```bash -ls docs/alpha/reports/*.json | wc -l # expect 10 -jq -e '.room_id and .game_id=="sfiii3" and .frames_received==60 and .transcript_checksum' docs/alpha/reports/*.json -# per pair checksums must match +opencade-match-verify attempt-01-host.json attempt-01-guest.json ``` -Pass is ≥8/10 COMPLETED with ordered 60-frame transcripts. Do not fabricate reports — they must come from real 2-machine runs. Until then, this gate is `HALT: physical LAN not available in this single-box env — local 10/10 + tooling ready`. +Pass is at least 8 verified attempts out of 10. Do not fabricate reports—they must come from real +two-machine runs. Until then, this gate is `HALT: physical LAN not available in this single-box +environment—local 10/10 + evidence tooling ready`. diff --git a/packages/networking/Cargo.toml b/packages/networking/Cargo.toml index 3539d7a..020cf75 100644 --- a/packages/networking/Cargo.toml +++ b/packages/networking/Cargo.toml @@ -8,7 +8,9 @@ repository = "https://github.com/opencade/opencade" rust-version = "1.78" [dependencies] +chrono = "0.4" opencade-emulator-sdk = { path = "../emulator-sdk" } +opencade-protocol = { path = "../protocol" } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "1" diff --git a/packages/networking/src/bin/opencade-match-probe.rs b/packages/networking/src/bin/opencade-match-probe.rs index b556119..04f6d90 100644 --- a/packages/networking/src/bin/opencade-match-probe.rs +++ b/packages/networking/src/bin/opencade-match-probe.rs @@ -1,5 +1,10 @@ +use chrono::Utc; use opencade_emulator_sdk::{MatchDescriptor, PeerRole, TransportKind}; use opencade_networking::{run_match_probe, MatchProbeConfig, UdpPeer}; +use opencade_protocol::{ + MatchReport, MatchReportClient, MatchReportProbe, MatchReportRole, MatchReportRoom, + MatchReportTransport, RoomState, MATCH_REPORT_SCHEMA_VERSION, +}; use std::env; use std::net::SocketAddr; use std::time::Duration; @@ -80,6 +85,8 @@ fn usage() -> &'static str { async fn main() -> Result<(), Box> { let arguments = Arguments::parse() .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + let room_id = arguments.room_id.clone(); + let game_id = arguments.game_id.clone(); let descriptor = MatchDescriptor { room_id: arguments.room_id, game_id: arguments.game_id, @@ -98,7 +105,31 @@ async fn main() -> Result<(), Box> { arguments.timeout, )?; let peer = UdpPeer::bind(arguments.local, arguments.peer).await?; - let report = run_match_probe(&peer, &config).await?; + let probe = run_match_probe(&peer, &config).await?; + let report = MatchReport { + schema_version: MATCH_REPORT_SCHEMA_VERSION, + exported_at: Utc::now(), + room: MatchReportRoom { + id: room_id, + game_id, + state: RoomState::Finished, + }, + probe: MatchReportProbe { + role: match probe.role { + PeerRole::Host => MatchReportRole::Host, + PeerRole::Guest => MatchReportRole::Guest, + }, + transport: MatchReportTransport::DirectUdp, + frames_sent: u32::try_from(probe.frames_sent).unwrap_or(u32::MAX), + frames_received: u32::try_from(probe.frames_received).unwrap_or(u32::MAX), + transcript_checksum: probe.transcript_checksum, + elapsed_ms: u32::try_from(probe.elapsed_ms).unwrap_or(u32::MAX), + }, + client: MatchReportClient { + platform: std::env::consts::OS.into(), + user_agent: format!("opencade-match-probe/{}", env!("CARGO_PKG_VERSION")), + }, + }; println!("{}", serde_json::to_string(&report)?); Ok(()) } diff --git a/packages/networking/src/bin/opencade-match-verify.rs b/packages/networking/src/bin/opencade-match-verify.rs new file mode 100644 index 0000000..5515b33 --- /dev/null +++ b/packages/networking/src/bin/opencade-match-verify.rs @@ -0,0 +1,107 @@ +use opencade_networking::verify_match_reports; +use opencade_protocol::MatchReport; +use serde::Serialize; +use std::fs::File; +use std::io::Read; +use std::{env, process}; + +const MAX_REPORT_BYTES: u64 = 64 * 1024; + +#[derive(Debug, Serialize)] +struct Failure { + verified: bool, + code: &'static str, + message: String, +} + +impl Failure { + fn new(code: &'static str, message: impl Into) -> Self { + Self { + verified: false, + code, + message: message.into(), + } + } +} + +fn main() { + match run() { + Ok(output) => println!("{output}"), + Err((failure, exit_code)) => { + let output = serde_json::to_string(&failure).unwrap_or_else(|_| { + "{\"verified\":false,\"code\":\"internal_error\",\"message\":\"failed to serialize error\"}".into() + }); + eprintln!("{output}"); + process::exit(exit_code); + } + } +} + +fn run() -> Result { + let mut args = env::args().skip(1); + let first_path = args.next().ok_or_else(usage_failure)?; + let second_path = args.next().ok_or_else(usage_failure)?; + if args.next().is_some() { + return Err(usage_failure()); + } + + let first = read_report(&first_path, "first")?; + let second = read_report(&second_path, "second")?; + let verification = verify_match_reports(&first, &second) + .map_err(|error| (Failure::new(error.code(), error.to_string()), 1))?; + serde_json::to_string(&verification).map_err(|_| { + ( + Failure::new("internal_error", "failed to serialize verification result"), + 2, + ) + }) +} + +fn read_report(path: &str, label: &'static str) -> Result { + let file = File::open(path).map_err(|_| { + ( + Failure::new( + "report_unreadable", + format!("could not read {label} report"), + ), + 2, + ) + })?; + let mut bytes = Vec::new(); + file.take(MAX_REPORT_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| { + ( + Failure::new( + "report_unreadable", + format!("could not read {label} report"), + ), + 2, + ) + })?; + if bytes.len() as u64 > MAX_REPORT_BYTES { + return Err(( + Failure::new("report_too_large", format!("{label} report exceeds 64 KiB")), + 2, + )); + } + serde_json::from_slice(&bytes).map_err(|_| { + ( + Failure::new( + "report_invalid", + format!("{label} report is not a canonical OpenCade match report"), + ), + 2, + ) + }) +} + +fn usage_failure() -> (Failure, i32) { + ( + Failure::new( + "usage", + "usage: opencade-match-verify FIRST_REPORT.json SECOND_REPORT.json", + ), + 2, + ) +} diff --git a/packages/networking/src/lib.rs b/packages/networking/src/lib.rs index 9e087af..05febb5 100644 --- a/packages/networking/src/lib.rs +++ b/packages/networking/src/lib.rs @@ -3,8 +3,10 @@ use std::net::SocketAddr; use tokio::sync::mpsc; mod probe; +mod report; pub use probe::{run_match_probe, MatchProbeConfig, MatchProbeReport, MAX_PROBE_FRAMES}; +pub use report::{verify_match_reports, MatchVerification, ReportVerificationError}; pub const MAX_INPUT_BYTES: usize = 256; pub const INPUT_QUEUE_CAPACITY: usize = 120; diff --git a/packages/networking/src/report.rs b/packages/networking/src/report.rs new file mode 100644 index 0000000..deb32f2 --- /dev/null +++ b/packages/networking/src/report.rs @@ -0,0 +1,208 @@ +use opencade_protocol::{ + MatchReport, MatchReportTransport, RoomState, MATCH_REPORT_SCHEMA_VERSION, +}; +use serde::Serialize; + +pub const ALPHA_MATCH_FRAMES: u32 = 60; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MatchVerification { + pub schema_version: u8, + pub verified: bool, + pub room_id: String, + pub game_id: String, + pub transport: MatchReportTransport, + pub frames_received: u32, + pub transcript_checksum: String, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ReportVerificationError { + #[error("{report} report uses unsupported schema version {version}")] + UnsupportedSchema { report: &'static str, version: u8 }, + #[error("{report} report does not describe a finished room")] + RoomNotFinished { report: &'static str }, + #[error("{report} report has an empty room or game identifier")] + EmptyCorrelation { report: &'static str }, + #[error("{report} report must contain exactly {ALPHA_MATCH_FRAMES} received frames")] + IncompleteTranscript { report: &'static str }, + #[error("{report} report sent fewer frames than it received")] + InvalidFrameCounts { report: &'static str }, + #[error("{report} report checksum must be 16 lowercase hexadecimal characters")] + InvalidChecksum { report: &'static str }, + #[error("reports describe different rooms")] + RoomMismatch, + #[error("reports describe different games")] + GameMismatch, + #[error("reports must come from opposite host and guest roles")] + RoleMismatch, + #[error("reports contain different transcript checksums")] + ChecksumMismatch, +} + +impl ReportVerificationError { + pub fn code(&self) -> &'static str { + match self { + Self::UnsupportedSchema { .. } => "schema_unsupported", + Self::RoomNotFinished { .. } => "room_not_finished", + Self::EmptyCorrelation { .. } => "correlation_missing", + Self::IncompleteTranscript { .. } => "transcript_incomplete", + Self::InvalidFrameCounts { .. } => "frame_counts_invalid", + Self::InvalidChecksum { .. } => "checksum_invalid", + Self::RoomMismatch => "room_mismatch", + Self::GameMismatch => "game_mismatch", + Self::RoleMismatch => "role_mismatch", + Self::ChecksumMismatch => "checksum_mismatch", + } + } +} + +pub fn verify_match_reports( + first: &MatchReport, + second: &MatchReport, +) -> Result { + validate_report("first", first)?; + validate_report("second", second)?; + + if first.room.id != second.room.id { + return Err(ReportVerificationError::RoomMismatch); + } + if first.room.game_id != second.room.game_id { + return Err(ReportVerificationError::GameMismatch); + } + if first.probe.role == second.probe.role { + return Err(ReportVerificationError::RoleMismatch); + } + if first.probe.transcript_checksum != second.probe.transcript_checksum { + return Err(ReportVerificationError::ChecksumMismatch); + } + + Ok(MatchVerification { + schema_version: MATCH_REPORT_SCHEMA_VERSION, + verified: true, + room_id: first.room.id.clone(), + game_id: first.room.game_id.clone(), + transport: MatchReportTransport::DirectUdp, + frames_received: ALPHA_MATCH_FRAMES, + transcript_checksum: first.probe.transcript_checksum.clone(), + }) +} + +fn validate_report( + label: &'static str, + report: &MatchReport, +) -> Result<(), ReportVerificationError> { + if report.schema_version != MATCH_REPORT_SCHEMA_VERSION { + return Err(ReportVerificationError::UnsupportedSchema { + report: label, + version: report.schema_version, + }); + } + if report.room.state != RoomState::Finished { + return Err(ReportVerificationError::RoomNotFinished { report: label }); + } + if report.room.id.trim().is_empty() || report.room.game_id.trim().is_empty() { + return Err(ReportVerificationError::EmptyCorrelation { report: label }); + } + if report.probe.frames_received != ALPHA_MATCH_FRAMES { + return Err(ReportVerificationError::IncompleteTranscript { report: label }); + } + if report.probe.frames_sent < report.probe.frames_received { + return Err(ReportVerificationError::InvalidFrameCounts { report: label }); + } + let checksum = report.probe.transcript_checksum.as_bytes(); + if checksum.len() != 16 + || !checksum + .iter() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) + { + return Err(ReportVerificationError::InvalidChecksum { report: label }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + use opencade_protocol::{ + MatchReportClient, MatchReportProbe, MatchReportRole, MatchReportRoom, + }; + + fn report(role: MatchReportRole) -> MatchReport { + MatchReport { + schema_version: MATCH_REPORT_SCHEMA_VERSION, + exported_at: Utc + .with_ymd_and_hms(2026, 8, 24, 12, 0, 0) + .single() + .expect("timestamp"), + room: MatchReportRoom { + id: "room-1".into(), + game_id: "sfiii3".into(), + state: RoomState::Finished, + }, + probe: MatchReportProbe { + role, + transport: MatchReportTransport::DirectUdp, + frames_sent: 64, + frames_received: ALPHA_MATCH_FRAMES, + transcript_checksum: "0376c2e852f4fd25".into(), + elapsed_ms: 240, + }, + client: MatchReportClient { + platform: "windows".into(), + user_agent: "opencade-test".into(), + }, + } + } + + #[test] + fn verifies_complementary_complete_reports() { + let verified = verify_match_reports( + &report(MatchReportRole::Host), + &report(MatchReportRole::Guest), + ) + .expect("paired reports"); + + assert!(verified.verified); + assert_eq!(verified.room_id, "room-1"); + assert_eq!(verified.frames_received, ALPHA_MATCH_FRAMES); + } + + #[test] + fn rejects_same_role_or_mismatched_checksum() { + let host = report(MatchReportRole::Host); + assert_eq!( + verify_match_reports(&host, &host), + Err(ReportVerificationError::RoleMismatch) + ); + + let mut guest = report(MatchReportRole::Guest); + guest.probe.transcript_checksum = "aaaaaaaaaaaaaaaa".into(); + assert_eq!( + verify_match_reports(&host, &guest), + Err(ReportVerificationError::ChecksumMismatch) + ); + } + + #[test] + fn rejects_incomplete_or_noncanonical_reports() { + let host = report(MatchReportRole::Host); + let mut guest = report(MatchReportRole::Guest); + guest.probe.frames_received = 59; + assert_eq!( + verify_match_reports(&host, &guest), + Err(ReportVerificationError::IncompleteTranscript { report: "second" }) + ); + + guest = report(MatchReportRole::Guest); + guest.schema_version = 2; + assert_eq!( + verify_match_reports(&host, &guest), + Err(ReportVerificationError::UnsupportedSchema { + report: "second", + version: 2, + }) + ); + } +} diff --git a/packages/networking/tests/report_verifier.rs b/packages/networking/tests/report_verifier.rs new file mode 100644 index 0000000..8f15502 --- /dev/null +++ b/packages/networking/tests/report_verifier.rs @@ -0,0 +1,107 @@ +use chrono::{TimeZone, Utc}; +use opencade_protocol::{ + MatchReport, MatchReportClient, MatchReportProbe, MatchReportRole, MatchReportRoom, + MatchReportTransport, RoomState, MATCH_REPORT_SCHEMA_VERSION, +}; +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn report(role: MatchReportRole) -> MatchReport { + MatchReport { + schema_version: MATCH_REPORT_SCHEMA_VERSION, + exported_at: Utc + .with_ymd_and_hms(2026, 8, 24, 12, 0, 0) + .single() + .expect("timestamp"), + room: MatchReportRoom { + id: "room-1".into(), + game_id: "sfiii3".into(), + state: RoomState::Finished, + }, + probe: MatchReportProbe { + role, + transport: MatchReportTransport::DirectUdp, + frames_sent: 64, + frames_received: 60, + transcript_checksum: "0376c2e852f4fd25".into(), + elapsed_ms: 240, + }, + client: MatchReportClient { + platform: "windows".into(), + user_agent: "opencade-test".into(), + }, + } +} + +fn fixture_dir() -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(); + std::env::temp_dir().join(format!( + "opencade-report-test-{}-{nonce}", + std::process::id() + )) +} + +#[test] +fn verifier_cli_returns_machine_readable_success_and_failure() { + let directory = fixture_dir(); + fs::create_dir(&directory).expect("fixture directory"); + let host_path = directory.join("host.json"); + let guest_path = directory.join("guest.json"); + fs::write( + &host_path, + serde_json::to_vec(&report(MatchReportRole::Host)).expect("host JSON"), + ) + .expect("host report"); + fs::write( + &guest_path, + serde_json::to_vec(&report(MatchReportRole::Guest)).expect("guest JSON"), + ) + .expect("guest report"); + + let success = Command::new(env!("CARGO_BIN_EXE_opencade-match-verify")) + .arg(&host_path) + .arg(&guest_path) + .output() + .expect("run verifier"); + assert!(success.status.success()); + let output: serde_json::Value = + serde_json::from_slice(&success.stdout).expect("success output JSON"); + assert_eq!(output["verified"], true); + assert_eq!(output["room_id"], "room-1"); + + let mut mismatched = report(MatchReportRole::Guest); + mismatched.probe.transcript_checksum = "aaaaaaaaaaaaaaaa".into(); + fs::write( + &guest_path, + serde_json::to_vec(&mismatched).expect("mismatch JSON"), + ) + .expect("mismatched report"); + let failure = Command::new(env!("CARGO_BIN_EXE_opencade-match-verify")) + .arg(&host_path) + .arg(&guest_path) + .output() + .expect("run verifier mismatch"); + assert_eq!(failure.status.code(), Some(1)); + let output: serde_json::Value = + serde_json::from_slice(&failure.stderr).expect("failure output JSON"); + assert_eq!(output["verified"], false); + assert_eq!(output["code"], "checksum_mismatch"); + + fs::write(&guest_path, vec![b' '; 64 * 1024 + 1]).expect("oversized report"); + let oversized = Command::new(env!("CARGO_BIN_EXE_opencade-match-verify")) + .arg(&host_path) + .arg(&guest_path) + .output() + .expect("run verifier with oversized report"); + assert_eq!(oversized.status.code(), Some(2)); + let output: serde_json::Value = + serde_json::from_slice(&oversized.stderr).expect("oversized output JSON"); + assert_eq!(output["code"], "report_too_large"); + + fs::remove_dir_all(directory).expect("remove fixture directory"); +} diff --git a/packages/networking/tests/two_process_probe.rs b/packages/networking/tests/two_process_probe.rs index 9cd7dd7..393876d 100644 --- a/packages/networking/tests/two_process_probe.rs +++ b/packages/networking/tests/two_process_probe.rs @@ -1,4 +1,5 @@ -use opencade_networking::MatchProbeReport; +use opencade_networking::verify_match_reports; +use opencade_protocol::MatchReport; use std::net::{SocketAddr, UdpSocket}; use std::process::{Child, Command, Output, Stdio}; @@ -46,7 +47,7 @@ fn spawn_probe( .expect("spawn match probe") } -fn successful_report(output: Output) -> MatchProbeReport { +fn successful_report(output: Output) -> MatchReport { assert!( output.status.success(), "probe failed: {}", @@ -65,12 +66,17 @@ fn independent_processes_complete_the_same_udp_transcript() { let host_report = successful_report(host.wait_with_output().expect("host output")); let guest_report = successful_report(guest.wait_with_output().expect("guest output")); - assert_eq!(host_report.frames_received, 60); - assert_eq!(guest_report.frames_received, 60); + assert_eq!(host_report.probe.frames_received, 60); + assert_eq!(guest_report.probe.frames_received, 60); assert_eq!( - host_report.transcript_checksum, - guest_report.transcript_checksum + host_report.probe.transcript_checksum, + guest_report.probe.transcript_checksum + ); + assert_eq!(host_report.room.id, "two-process-room"); + assert_eq!(guest_report.room.id, "two-process-room"); + assert!( + verify_match_reports(&host_report, &guest_report) + .expect("paired evidence") + .verified ); - assert_eq!(host_report.room_id, "two-process-room"); - assert_eq!(guest_report.room_id, "two-process-room"); } diff --git a/packages/protocol/src/generated/MatchReport.ts b/packages/protocol/src/generated/MatchReport.ts new file mode 100644 index 0000000..91231c4 --- /dev/null +++ b/packages/protocol/src/generated/MatchReport.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { MatchReportClient } from "./MatchReportClient"; +import type { MatchReportProbe } from "./MatchReportProbe"; +import type { MatchReportRoom } from "./MatchReportRoom"; + +/** + * Canonical, redacted evidence emitted by both the desktop and standalone LAN probe. + */ +export type MatchReport = { + schema_version: number; + exported_at: string; + room: MatchReportRoom; + probe: MatchReportProbe; + client: MatchReportClient; +}; diff --git a/packages/protocol/src/generated/MatchReportClient.ts b/packages/protocol/src/generated/MatchReportClient.ts new file mode 100644 index 0000000..ec4099a --- /dev/null +++ b/packages/protocol/src/generated/MatchReportClient.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Non-sensitive producer metadata used to diagnose platform-specific alpha failures. + */ +export type MatchReportClient = { platform: string; user_agent: string }; diff --git a/packages/protocol/src/generated/MatchReportProbe.ts b/packages/protocol/src/generated/MatchReportProbe.ts new file mode 100644 index 0000000..d468658 --- /dev/null +++ b/packages/protocol/src/generated/MatchReportProbe.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { MatchReportRole } from "./MatchReportRole"; +import type { MatchReportTransport } from "./MatchReportTransport"; + +/** + * Deterministic transport evidence from one side of an alpha match. + */ +export type MatchReportProbe = { + role: MatchReportRole; + transport: MatchReportTransport; + frames_sent: number; + frames_received: number; + transcript_checksum: string; + elapsed_ms: number; +}; diff --git a/packages/protocol/src/generated/MatchReportRole.ts b/packages/protocol/src/generated/MatchReportRole.ts new file mode 100644 index 0000000..68e582e --- /dev/null +++ b/packages/protocol/src/generated/MatchReportRole.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Peer role retained in a redacted alpha report. User identifiers are deliberately omitted. + */ +export type MatchReportRole = "host" | "guest"; diff --git a/packages/protocol/src/generated/MatchReportRoom.ts b/packages/protocol/src/generated/MatchReportRoom.ts new file mode 100644 index 0000000..b16a26f --- /dev/null +++ b/packages/protocol/src/generated/MatchReportRoom.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RoomState } from "./RoomState"; + +/** + * Privacy-minimized room correlation included in an alpha report. + */ +export type MatchReportRoom = { id: string; game_id: string; state: RoomState }; diff --git a/packages/protocol/src/generated/MatchReportTransport.ts b/packages/protocol/src/generated/MatchReportTransport.ts new file mode 100644 index 0000000..c7a5c78 --- /dev/null +++ b/packages/protocol/src/generated/MatchReportTransport.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Transport values accepted by the v1 alpha evidence contract. + */ +export type MatchReportTransport = "direct_udp"; diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index a0f246e..df5d457 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -6,6 +6,12 @@ export type { ChatPayload, MatchEndpointPayload, MatchProbeCompletedPayload, + MatchReport, + MatchReportClient, + MatchReportProbe, + MatchReportRole, + MatchReportRoom, + MatchReportTransport, ChallengePayload, ChallengeState, SessionOfferPayload, diff --git a/packages/protocol/src/lib.rs b/packages/protocol/src/lib.rs index 0021a6c..f25af96 100644 --- a/packages/protocol/src/lib.rs +++ b/packages/protocol/src/lib.rs @@ -15,6 +15,9 @@ use uuid::Uuid; /// Canonical protocol version. All messages SHOULD use this value. pub const PROTOCOL_VERSION: &str = "1.0"; +/// Canonical schema version for redacted LAN alpha evidence reports. +pub const MATCH_REPORT_SCHEMA_VERSION: u8 = 1; + /// Returns `true` if the supplied version string is supported. /// /// Accepts both canonical `"1.0"` and compat `"1"`. @@ -177,6 +180,63 @@ pub struct MatchProbeCompletedPayload { pub frames_received: u32, pub transcript_checksum: String, } + +/// Peer role retained in a redacted alpha report. User identifiers are deliberately omitted. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/MatchReportRole.ts")] +#[serde(rename_all = "snake_case")] +pub enum MatchReportRole { + Host, + Guest, +} + +/// Transport values accepted by the v1 alpha evidence contract. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/MatchReportTransport.ts")] +#[serde(rename_all = "snake_case")] +pub enum MatchReportTransport { + DirectUdp, +} + +/// Privacy-minimized room correlation included in an alpha report. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/MatchReportRoom.ts")] +pub struct MatchReportRoom { + pub id: String, + pub game_id: String, + pub state: RoomState, +} + +/// Deterministic transport evidence from one side of an alpha match. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/MatchReportProbe.ts")] +pub struct MatchReportProbe { + pub role: MatchReportRole, + pub transport: MatchReportTransport, + pub frames_sent: u32, + pub frames_received: u32, + pub transcript_checksum: String, + pub elapsed_ms: u32, +} + +/// Non-sensitive producer metadata used to diagnose platform-specific alpha failures. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/MatchReportClient.ts")] +pub struct MatchReportClient { + pub platform: String, + pub user_agent: String, +} + +/// Canonical, redacted evidence emitted by both the desktop and standalone LAN probe. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/MatchReport.ts")] +pub struct MatchReport { + pub schema_version: u8, + pub exported_at: DateTime, + pub room: MatchReportRoom, + pub probe: MatchReportProbe, + pub client: MatchReportClient, +} /// Room lifecycle states. Serialized as `snake_case` strings. /// ARCHITECTURE.md §9 DB stores WAITING/READY/PLAYING/FINISHED/CANCELLED (upper); /// AGENTS.md describes WAITING→CHALLENGING→CONNECTING→PLAYING→FINISHED|CANCELLED. diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index f952105..9efe685 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2,6 +2,10 @@ import type { ChallengePayload } from "./generated/ChallengePayload.js"; import type { ChatPayload } from "./generated/ChatPayload.js"; import type { MatchEndpointPayload } from "./generated/MatchEndpointPayload.js"; import type { MatchProbeCompletedPayload } from "./generated/MatchProbeCompletedPayload.js"; +import type { MatchReport } from "./generated/MatchReport.js"; +import type { MatchReportClient } from "./generated/MatchReportClient.js"; +import type { MatchReportProbe } from "./generated/MatchReportProbe.js"; +import type { MatchReportRoom } from "./generated/MatchReportRoom.js"; import type { PresencePayload } from "./generated/PresencePayload.js"; import type { RoomPayload } from "./generated/RoomPayload.js"; import type { SessionAnswerPayload } from "./generated/SessionAnswerPayload.js"; @@ -13,6 +17,10 @@ export type { ChatPayload, MatchEndpointPayload, MatchProbeCompletedPayload, + MatchReport, + MatchReportClient, + MatchReportProbe, + MatchReportRoom, PresencePayload, RoomPayload, SessionAnswerPayload, @@ -21,6 +29,8 @@ export type { }; export type { ChallengeState } from "./generated/ChallengeState.js"; export type { RoomState } from "./generated/RoomState.js"; +export type { MatchReportRole } from "./generated/MatchReportRole.js"; +export type { MatchReportTransport } from "./generated/MatchReportTransport.js"; export type HelloPayload = { user_id: string;