A Rust + WebAssembly implementation for securely transferring data between two parties, with a Trusted Third Party (TTP) available only for dispute resolution.
AI Note: While the protocol implementations compiled to Wasm are written in the normal way, the "runtime" code that invokes them has been edited with the aid of Claude Code to assemble a demo and associated documentation.
- What this project does
- Protocol overview
- Project structure
- Prerequisites
- Building the project
- Key management
- Running the protocol
- Understanding the output
- Testing timeout and TTP scenarios
Two parties β Source (RS) and Destination (RD) β want to exchange a piece of data such that either both parties end up with a cryptographic proof the exchange happened, or neither does. This is the fairness guarantee.
The protocol is optimistic: in the optimal case, the two parties complete the exchange directly between themselves without involving a third party at all. The TTP is only contacted when one party suspects the other is misbehaving or has gone offline.
At the end of a successful exchange both parties independently print the same JSON receipt to stdout:
{
"source_id": "<hex of source long-term public key>",
"dest_id": "<hex of destination long-term public key>",
"data": "SomeStringHere",
"hash": "<BLAKE3 hex of SomeStringHere>",
"signature_source": "<hex of BLAKE3(secret_as)>",
"signature_destination": "<hex of BLAKE3(secret_ad)>",
"status": "commit",
"method": "direct"
}status: "commit"β the exchange completed successfully.status: "rollback"β the exchange was aborted.method: "direct"β completed without TTP intervention.method: "arbitrated"β the TTP was contacted to resolve or abort.
| Name | Short | Role |
|---|---|---|
| Agent Source | AS | Receives input from RS and takes care of the logical part of the fair exchange protocol |
| Agent Destination | AD | Receives input from RD and takes care of the logical part of the fair exchange protocol |
| Runtime Source | RS | Initiates the exchange, loads AS component, holds the data and interacts with AS |
| Runtime Destination | RD | Receives the data from RS, loads AD component, interacts with AD |
| Runtime TTP | TTP | Trusted third party; only contacted on timeout |
RS RD TTP | | | |-- StringTransfer (data) --------->| | | | | |<== Fair Exchange Protocol =======>| | | | | |-- CommunicationMessage(AS) ------>| | |<-- CommunicationMessage(AD) ------| | |-- secret_as (32 bytes) ---------->| | |<-- secret_ad (32 bytes) ----------| | | | | [Both print JSON receipt to stdout]
- CommunicationMessage(AS): Source's ephemeral signing key, a contract (hash of data, both long-term public keys,
commitment_as = BLAKE3(secret_as)), and Source's signature over it. - CommunicationMessage(AD): Same structure but for Destination β includes
commitment_ad = BLAKE3(secret_ad)and Destination's signature. - secret_as / secret_ad: 32-byte random secrets. Once received and verified against the commitment, the holder has proof the other party committed.
If either party does not receive a message within the timeout window (default 5 seconds):
- Source hasn't received AD's verification β sends AbortRequest to TTP.
- Source or Destination hasn't received the other's secret β sends ResolveRequest to TTP.
The TTP is stateful per session (keyed by Source's ephemeral verifying key). Once it decides Abort or Resolve for a session, it never changes its mind, ensuring consistency.
You must share the public key files with the other party before running. Source needs dest.key.pub; Destination needs source.key.pub.
corndog/
βββ common/ # Shared TCP framing and BLAKE3 hashing utilities
βββ agent_source_destination/ # WASM component: fair-exchange logic for Source and Destination
β βββ src/
β β βββ agent_source.rs # Source-side protocol state machine
β β βββ agent_destination.rs# Destination-side protocol state machine
β β βββ identity.rs # Ed25519 key and commitment types
β β βββ types.rs # Shared message types (CommunicationMessage, etc.)
β βββ wit/world.wit # WIT interface exported by this component
βββ agent_ttp/ # WASM component: TTP arbitration logic
β βββ src/
β β βββ agent_ttp.rs # Session-keyed abort/resolve state machine
β β βββ identity.rs, types.rs
β βββ wit/world.wit # WIT interface exported by this component
βββ runtime_source/ # Native binary: Source host runtime
βββ runtime_destination/ # Native binary: Destination host runtime
βββ runtime_ttp/ # Native binary: TTP host runtime (serves multiple sessions)
The two agent crates are compiled to wasm32-wasip2 and loaded at runtime by the host binaries via Wasmtime. All cryptographic protocol logic lives inside the WASM components; the host runtimes handle only networking and I/O.
- Rust (stable toolchain) β install via rustup
- wasm32-wasip2 target β needed to compile the agent components:
rustup target add wasm32-wasip2
wasm-toolsβ used internally bywit-bindgenand Wasmtime's component model; install via Cargo or your package manager:cargo install wasm-tools
No other runtime dependencies are required. All cryptographic primitives (ed25519-dalek, blake3) are pulled in as Cargo crates.
Build in two steps: the WASM agent components first, then the native host runtimes.
cargo build --release --target wasm32-wasip2 \
-p agent_source_destination \
-p agent_ttpThis produces:
target/wasm32-wasip2/release/agent_source_destination.wasm
target/wasm32-wasip2/release/agent_ttp.wasm
cargo build --release \
-p runtime_source \
-p runtime_destination \
-p runtime_ttpThis produces:
target/release/runtime_source
target/release/runtime_destination
target/release/runtime_ttp
All three host binaries expect the WASM files from Step 1 to exist at the paths above relative to the working directory where you run them, so run them from the project root.
Each party needs an Ed25519 key pair. Key files are plain hex strings (64 hex characters = 32 bytes) stored in plain text files.
Use --generate-keypair to create a key pair without starting an exchange. It is mutually exclusive with the normal operation arguments and exits immediately after writing the files.
# Source generates source.key and source.key.pub
./target/release/runtime_source --generate-keypair source.key# Destination generates dest.key and dest.key.pub
./target/release/runtime_destination --generate-keypair dest.keyThe private key is written to the path you supply; the companion .pub file is created at <path>.pub automatically. If either file already exists the command will exit with an error. To overwrite, add --force / -f:
./target/release/runtime_source --generate-keypair source.key --forceBefore running the full protocol, the two parties must share their public key files out-of-band:
| Party | File to share | Used by |
|---|---|---|
| Source | source.key.pub |
Destination (--source-public-key) |
| Destination | dest.key.pub |
Source (--destination-public-key) |
The Destination runtime verifies that the source_pubkey embedded in the TCP StringTransfer message matches the file provided via --source-public-key, and rejects the connection if they differ.
Both private and public key files contain a single line: a lowercase hex string with no whitespace, representing 32 bytes. You can inspect or generate them manually:
# Generate a raw 32-byte key and hex-encode it
openssl rand -hex 32 > mykey.keyOpen three terminals in the project root. Start them in this order.
./target/release/runtime_ttpTTP listens on 0.0.0.0:9705 by default. Override with --listen-addr ADDR.
echo "SomeStringHere" | ./target/release/runtime_destination \
--dest-private-key dest.key \
--source-public-key source.key.pubDestination listens on 0.0.0.0:7760 by default and waits for Source. Override with --listen-addr ADDR. The TTP address defaults to 0.0.0.0:9705; override with --ttp-addr ADDR.
echo "SomeStringHere" | ./target/release/runtime_source \
--source-private-key source.key \
--destination-public-key dest.key.pubThe Destination address defaults to 0.0.0.0:7760 and TTP to 0.0.0.0:9705; override with --destination-addr ADDR and --ttp-addr ADDR respectively.
Important: The string you echo must be identical in both Terminal 2 and Terminal 3. The protocol verifies this via BLAKE3 hash comparison before proceeding.
Both Terminal 2 and Terminal 3 print to stdout:
{
"source_id": "f425f42fa0de1c5023a3b044faeb67c02616b8a0deee185e7422cabb441f924c",
"dest_id": "fc1ee006b897eba08872ee5272e6a1831d1556c9f868263bb6445c4e618f4289",
"data": "SomeStringHere",
"hash": "4b38951afc2ca66b16842e904f2898103b72b396779c31286393884492c8ed15",
"signature_source": "6a9d3209eb5f19125db22f0b29127349dbbe1b6a8f3d2d3eb941042e937433bf",
"signature_destination": "0433a9e3761831bd2e2e7d5df89e4f532c9a12ac52570b740d018966e1ef547c",
"status": "commit",
"method": "direct"
}Debug logs from both runtimes and the WASM agents go to stderr and do not appear on stdout. To silence them entirely: append 2>/dev/null to your commands.
| Field | Meaning |
|---|---|
source_id |
Source's long-term Ed25519 public key (hex). Identifies who initiated the exchange. |
dest_id |
Destination's long-term Ed25519 public key (hex). Identifies who received it. |
data |
The actual string that was exchanged. |
hash |
BLAKE3 hash of data. Both parties compute this independently β if they disagree, the protocol aborts. |
signature_source |
BLAKE3(secret_as) β Source's commitment. Proof that Source committed to this exchange. |
signature_destination |
BLAKE3(secret_ad) β Destination's commitment. Proof that Destination committed to this exchange. |
status |
"commit" β exchange succeeded. "rollback" β exchange was aborted. |
method |
"direct" β no TTP involvement. "arbitrated" β TTP was contacted to resolve or abort. |
Both parties produce identical JSON on success. You can verify fairness by checking that both receipts match and that BLAKE3(data) == hash.
The runtimes contain commented-out sleep calls for simulating party misbehaviour. They are marked with ===== TEST CASE OF SLEEPING ===== comments.
To simulate Source going offline after sending its contract (tests Destination's abort path):
In runtime_source/src/main.rs, find the counter == 2 block and uncomment:
tokio::time::sleep(DELAY_SECRET_AS).await;To simulate Destination going offline before sending its verification (tests Source's abort path):
In runtime_destination/src/main.rs, find the counter == 1 block and uncomment:
tokio::time::sleep(DELAY_MSG_AD).await;To simulate Destination going offline before sending its secret (tests Source's resolve path):
In runtime_destination/src/main.rs, find the counter == 2 block and uncomment:
tokio::time::sleep(DELAY_SECRET_AD).await;When a timeout occurs, the affected party contacts the TTP. The TTP either:
- ABORTs β if contacted before any secret is revealed; the exchange is cancelled.
- RESOLVEs β if contacted after at least one secret was revealed; the TTP helps complete the exchange.
The TTP guarantees consistency: once a session is ABORTED it can never be RESOLVED, and vice versa.