Skip to main content

zunia.connect.v2

Protocol between a dApp, a relay, and Zunia mobile. The TypeScript types and crypto live in @zunialab/sdk-core. The Dart wallet implements the same vectors.

HTTP​

POST /v1/connect/sessions with the page's Origin. Response:

{
v: "zunia.connect.v2",
sessionId: string,
dappToken: string, // stays with the dApp, never in the QR
walletJoinToken: string, // single use, goes in the QR
verifiedOrigin: string | null,
expiresAt: number,
wsUrl: string,
}

WebSocket​

/v1/connect/ws. Tokens travel in Sec-WebSocket-Protocol (zunia.token.…), not in the query string. The wallet's join token is consumed on first use and replaced by a resumeToken.

Relay frames (t) are the only thing the relay reads: welcome, hello, peer, msg, paired, ping / pong, error, closed. Application payloads sit inside sealed msg frames (n sequence, c ciphertext).

Only the wallet may send hello (its public key) and paired. paired locks the room and extends the TTL to 24 hours. Unpaired rooms die after 10 minutes. A socket is only replaced by a holder of that role's token.

Crypto​

  • Key agreement: X25519
  • KDF: HKDF-SHA256 over a transcript of both public keys and the session id
  • Sealed frames: ChaCha20-Poly1305, with the sequence number in the AAD
  • Sequence numbers: monotonic, reject duplicates and replays
  • Verification code: 6 digits derived from the shared secret, shown on both sides

Test vectors: @zunialab/sdk-core/test-vectors/connect-v2-vectors.json.

Pairing URI​

zunia://connect?… (also accepted as https://zunialab.com/connect?…). Query holds the session id, the join token and the dApp public key. It does not hold the dApp token.

What the relay sees​

Session id, hashed tokens, verifiedOrigin, role, timestamps, ciphertext length. Not keys, not accounts, not the sign-in message, not the transaction.

Tokens are stored as SHA-256 hashes and compared with timingSafeEqual. Sessions persist in Postgres when DATABASE_URL is set; otherwise they live in memory and die on restart. Run one instance: live sockets are not shared.

Limits: session creation per client address, frame size and rate per socket, a global room cap, a bounded offline queue. TRUST_PROXY must match how you terminate TLS, or rate limits attach to the wrong address.

This is not a multi-instance relay. There is no public deployment.