174 lines
6.6 KiB
Markdown
174 lines
6.6 KiB
Markdown
# 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` | `%` ✅ `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";
|
||
}
|
||
```
|