Skip to content

Connection URI and key exchange

A WizardConnect session starts with the dapp generating a connection URI. The wallet scans this URI (typically as a QR code) and both sides use it to establish an encrypted channel.

URI format

wiz://?p=<pubkey_bech32>&s=<secret_bech32>

When using a non-default relay:

wiz://<hostname>:<port>?p=<pubkey_bech32>&s=<secret_bech32>&pr=<protocol>
Parameter Encoding Size Meaning
p bech32-padded 32 bytes Dapp's Nostr public key (x-only secp256k1)
s bech32-padded 8 bytes Shared secret for key exchange verification
hostname URL authority Relay hostname (default: relay.cauldron.quest)
port URL authority Relay port (default: 443)
pr query param ws or wss (default: wss, omitted when default)

The URI omits the authority entirely when relay defaults are used, keeping QR codes compact.

QR code encoding — alphanumeric mode

QR codes support several character encodings. Alphanumeric mode encodes each character in 5.5 bits instead of 8 bits (byte mode), producing ~30 % smaller codes for the same data.

The alphanumeric charset covers: A–Z, 0–9, and the symbols $, %, *, +, -, ., /, :, and space.

The bech32 alphabet (a–z, 2–7) maps entirely into this charset when uppercased — every bech32 character is either a letter or a digit. The URI scheme characters (WIZ, ://) and port numbers are also fully alphanumeric-safe.

However, the standard URI separators ?, =, and & are not in the alphanumeric charset. Their percent-encoded forms are safe:

Char Encoding All chars alphanumeric?
? %3F %3F
= %3D %3D
& %26 %26

encodeKeyExchangeURI() returns both a standard URI and a QR-safe URI:

const { uri, qrUri } = encodeKeyExchangeURI(publicKey, secret);
// uri    → "wiz://?p=qpzry9x8...&s=qpzry9x8"      (standard, for copy-paste)
// qrUri  → "WIZ://%3FP%3DQPZRY9X8...%26S%3DQPZRY9X8"  (QR alphanumeric-safe)

Use uri for display and clipboard copy. Use qrUri for QR code generation.

decodeKeyExchangeURI() accepts both formats — it detects the QR encoding by looking for %3f without a ? and reverses the substitutions before parsing. Both uppercase and lowercase variants are accepted.

Example URIs

Default relay, standard format (as produced by encodeKeyExchangeURI):

wiz://?p=qpzry9x8gf2tvdw0s3jn54khce6mua7lqpzry9x8gf2tvdw0s3jn54khce6mua7l&s=qpzry9x8

QR-alphanumeric-safe format (use this for QR code generation):

WIZ://%3FP%3DQPZRY9X8GF2TVD...%26S%3DQPZRY9X8

Custom relay:

wiz://my-relay.example.com:8443?p=qpzry9x8...&s=qpzry9x8&pr=wss

Credentials

The dapp generates a fresh keypair and a random 8-byte secret for each new connection:

interface KeyExchangeCredentials {
  privateKey: string;  // hex, 32 bytes — dapp's Nostr private key
  publicKey: string;   // hex, 32 bytes — derived from privateKey (x-only)
  secret: string;      // hex, 8 bytes  — shared secret for MITM prevention
}

generateKeyExchangeCredentials() creates a fresh set. encodeKeyExchangeURI(publicKey, secret) produces the scannable URI from them.

Reconnection

A dapp can reconnect to an existing session by passing existingCredentials: { privateKey, secret } to initiateDappRelay(). The wallet's public key is re-exchanged on reconnect.

Key exchange flow

Once the wallet scans the URI, the following happens over the relay:

1. Wallet sends:  wallet_ready { ..., public_key: <wallet_nostr_pubkey_hex>,
                                       secret: <shared_secret_hex> }
   (wallet_ready carries both the application handshake data and the key exchange fields)

2. Dapp receives wallet_ready:
   - Verifies secret matches its own credentials.secret.
   - Calls client.setPairedPublicKey(walletPublicKey).
   - Fires keyexchangecomplete event.
   - Processes the session data (xpubs) as usual.
   - From this point, all outbound messages are encrypted to the wallet's pubkey.

3. Both sides now have each other's Nostr public keys.
   Subsequent messages are encrypted and filtered to the paired peer.

The key exchange data (public_key, secret) is embedded directly in wallet_ready. The wallet sends a single message and the dapp learns the wallet's pubkey atomically with the session data.

Why a shared secret?

The secret prevents a MITM or a curious third party from completing the key exchange with a wallet using a stolen URI. The wallet must echo back the correct 8-byte secret; if it doesn't match, the dapp ignores the wallet_ready. Eight bytes (64-bit) is enough entropy for a short-lived pairing code — it is not a long-term secret.

Why Nostr keys?

Nostr keys are secp256k1 keys, the same curve used in Bitcoin/BCH. Using a Nostr relay and NIP-17 gift wrap gets us:

  • A widely-deployed, censorship-resistant message bus.
  • End-to-end encryption with a simple, audited encryption scheme.
  • No WizardConnect-operated server in the critical path — anyone can run a relay.

The "Nostr x-only pubkey" format is just the 32-byte x-coordinate of the secp256k1 public key (no 02/03 prefix). deriveNostrPublicKey(privateKey) computes this from a 32-byte private key.

Peer filtering

After key exchange, RelayClient compares the pubkey field of every decrypted Nostr event against the paired public key. Messages from any other pubkey are dropped with a warning log. wallet_ready bypasses this filter because the dapp does not yet know the wallet's pubkey when it first arrives — it is the message that establishes the pairing.

API surface

// Generate fresh credentials (called internally by initiateDappRelay)
generateKeyExchangeCredentials(): KeyExchangeCredentials

// Encode a URI from an existing keypair — returns both standard and QR-safe forms
encodeKeyExchangeURI(publicKey: string, secret: string, options?): KeyExchangeURIResult

interface KeyExchangeURIResult {
  uri: string;    // standard URI for copy-paste: wiz://?p=...&s=...
  qrUri: string;  // QR-alphanumeric-safe URI: WIZ://%3FP%3D...%26S%3D...
}

// Decode and validate a wiz:// URI
// Accepts standard format, QR format (%3F/%3D/%26), uppercase, and mixed-case
decodeKeyExchangeURI(uri: string): DecodedKeyExchangeURI

interface DecodedKeyExchangeURI {
  publicKey: string;          // hex, 32 bytes
  secret: string;             // hex, 8 bytes
  hostname: string;
  port: number;
  protocol: "ws" | "wss";
}