2026-02-26 11:19:47 +01:00
|
|
|
|
# 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 |
|
2026-04-14 08:40:53 +02:00
|
|
|
|
| `hostname` | URL authority | — | Relay hostname (default: `relay.riften.net`) |
|
2026-02-26 11:19:47 +01:00
|
|
|
|
| `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` | `%` ✅ `3` ✅ `F` ✅ |
|
|
|
|
|
|
| `=` | `%3D` | `%` ✅ `3` ✅ `D` ✅ |
|
|
|
|
|
|
| `&` | `%26` | `%` ✅ `2` ✅ `6` ✅ |
|
|
|
|
|
|
|
|
|
|
|
|
`encodeKeyExchangeURI()` returns both a standard URI and a QR-safe URI:
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
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:
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
// 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";
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|