Relay Client Auth
Motivation
WalletConnect supports e2e encrypted messaging between multiple clients. Should a recipient be offline, WalletConnect stores the encrypted messages in the recipient's mailbox until they reconnect. The address of the mailbox is a unique sticky client_id that clients present when they connect to WalletConnect.
This document discusses the creation and usage of the client_id for authentication
Overview
WalletConnect expects an Authorization: Bearer <signed jwt>
header when establishing the Websocket connection. Where websocket headers are not supported i.e. browsers, use url query param ?auth=<signed jwt>
. Server should support both of these mechanisms.
The client_id
is a DID of the public key for the key pair generated by the client when instantiating the SDK and persisted for the entire lifecyle.
Clients will generate a unique id per app intialization which will be used as a subject when signing a JWT with the client_id
as the issuer and is persisted through duration of the app lifecycle.
Specification
Here we describe how a client can generate a Ed25519 key pair to authenticate itself using the public key as it's client id
Additionally we describe how we can construct and sign a JWT using a client-side generated unique id as subject
Finally we describe how the public key is encoded as did:key
identifier which is used as client id.
API
// ---------- API ----------------------------------------------- //
function signJWT(subject: string, keyPair: ed25519.KeyPair): Promise<string>;
function verifyJWT(jwt: string): Promise<boolean>;
// ---------- Utilities ----------------------------------------------- //
function decodeJSON(str: string): any;
function encodeJSON(val: any): string;
function encodeIss(publicKey: Uint8Array): string;
function decodeIss(issuer: string): Uint8Array;
function encodeSig(bytes: Uint8Array): string;
function decodeSig(encoded: string): Uint8Array;
function encodeData(params: JWTData): string;
function decodeData(jwt: string): JWTData;
function encodeJWT(params: JWTSigned): string;
function decodeJWT(jwt: string): JWTSigned;
Reference Implementation
import * as ed25519 from "@stablelib/ed25519";
import { concat } from "uint8arrays/concat";
import { toString } from "uint8arrays/to-string";
import { fromString } from "uint8arrays/from-string";
import { fromMiliseconds } from "@walletconnect/time";
import { safeJsonParse, safeJsonStringify } from "@walletconnect/safe-json";
// ---------- Interfaces ----------------------------------------------- //
interface JWTHeader {
alg: "EdDSA";
typ: "JWT";
}
interface JWTPayload {
iss: string;
sub: string;
}
interface JWTData {
header: JWTHeader;
payload: JWTPayload;
}
interface JWTSigned extends JWTData {
signature: Uint8Array;
}
// ---------- Constants ----------------------------------------------- //
const JWT_ALG: JWTHeader["alg"] = "EdDSA";
const JWT_TYP: JWTHeader["typ"] = "JWT";
const JWT_DELIMITER = ".";
const JWT_ENCODING = "base64url";
const JSON_ENCODING = "utf8";
const DATA_ENCODING = "utf8";
const DID_DELIMITER = ":";
const DID_PREFIX = "did";
const DID_METHOD = "key";
const MULTICODEC_ED25519_ENCODING = "base58btc";
const MULTICODEC_ED25519_BASE = "z";
const MULTICODEC_ED25519_HEADER = "K36";
const MULTICODEC_ED25519_LENGTH = 32;
// ---------- JSON ----------------------------------------------- //
function decodeJSON(str: string): any {
return safeJsonParse(toString(fromString(str, JWT_ENCODING), JSON_ENCODING));
}
function encodeJSON(val: any): string {
return toString(
fromString(safeJsonStringify(val), JSON_ENCODING),
JWT_ENCODING
);
}
// ---------- Issuer ----------------------------------------------- //
function encodeIss(publicKey: Uint8Array): string {
const keyType = fromString(
MULTICODEC_ED25519_KEY_TYPE,
MULTICODEC_ED25519_ENCODING
);
const multicodec = toString(
concat([header, publicKey]),
MULTICODEC_ED25519_ENCODING
);
const multibase = MULTIBASE_BASE58BTC_PREFIX + multicodec;
return [DID_PREFIX, DID_METHOD, multibase].join(DID_DELIMITER);
}
function decodeIss(issuer: string): Uint8Array {
const [prefix, method, multibase] = issuer.split(DID_DELIMITER);
if (prefix !== DID_PREFIX || method !== DID_METHOD) {
throw new Error(`Issuer must be a DID with method "key"`);
}
const base = multibase.slice(0, 1);
if (base !== MULTIBASE_BASE58BTC_PREFIX) {
throw new Error(`Issuer must be a multibase with encoding base58btc`);
}
const multicodec = fromString(
multibase.slice(1),
MULTICODEC_ED25519_ENCODING
);
const keyType = toString(multicodec.slice(0, 2), MULTICODEC_ED25519_ENCODING);
if (keyType !== MULTICODEC_ED25519_KEY_TYPE) {
throw new Error(`Issuer must be a public key with type "Ed25519"`);
}
const publicKey = multicodec.slice(2);
if (publicKey.length !== MULTICODEC_ED25519_LENGTH) {
throw new Error(`Issuer must be a public key with length 32 bytes`);
}
return publicKey;
}
// ---------- Sig ----------------------------------------------- //
function encodeSig(bytes: Uint8Array): string {
return toString(bytes, JWT_ENCODING);
}
function decodeSig(encoded: string): Uint8Array {
return fromString(encoded, JWT_ENCODING);
}
// ---------- Data ----------------------------------------------- //
function encodeData(params: JWTData): Uint8Array {
return fromString(
[encodeJSON(params.header), encodeJSON(params.payload)].join(JWT_DELIMITER),
DATA_ENCODING
);
}
function decodeData(data: Uint8Array): JWTData {
const params = toString(data, DATA_ENCODING).split(JWT_DELIMITER);
const header = decodeJSON(params[0]);
const payload = decodeJSON(params[1]);
return { header, payload };
}
// ---------- JWT ----------------------------------------------- //
function encodeJWT(params: JWTSigned): string {
return [
encodeJSON(params.header),
encodeJSON(params.payload),
encodeSig(params.signature),
].join(JWT_DELIMITER);
}
function decodeJWT(jwt: string): JWTSigned {
const params = jwt.split(JWT_DELIMITER);
const header = decodeJSON(params[0]);
const payload = decodeJSON(params[1]);
const signature = decodeSig(params[2]);
return { header, payload, signature };
}
// ---------- API ----------------------------------------------- //
async function signJWT(
sub: string,
aud: string,
ttl: number,
keyPair: ed25519.KeyPair
) {
const header = { alg: JWT_ALG, typ: JWT_TYP };
const iss = encodeIss(keyPair.publicKey);
const iat = fromMiliseconds(Date.now());
const exp = iat + ttl;
const payload = { iss, sub, aud, iat, exp };
const data = encodeData({ header, payload });
const signature = ed25519.sign(keyPair.secretKey, data);
return encodeJWT({ header, payload, signature });
}
async function verifyJWT(jwt: string) {
const { header, payload, signature } = decodeJWT(jwt);
if (header.alg !== JWT_ALG || header.typ !== JWT_TYP) {
throw new Error("JWT must use EdDSA algorithm");
}
const publicKey = decodeIss(payload.iss);
const data = encodeData({ header, payload });
return ed25519.verify(publicKey, data, signature);
}
Test Cases
// Client will sign a unique identifier as the subject
const sub =
"c479fe5dc464e771e78b193d239a65b58d278cad1c34bfb0b5716e5bb514928e";
// Client will include the server endpoint as audience
const aud = "wss://relay.walletconnect.com";
// Client will use the JWT for 24 hours
const ttl = 86400;
// Fixed seed to generate the same key pair
const seed = fromString(
"58e0254c211b858ef7896b00e3f36beeb13d568d47c6031c4218b87718061295",
"base16"
);
// Fixed issuedAt timestamp in seconds
const iat = 1656910097;
// Generate key pair from seed
const keyPair = ed25519.generateKeyPairFromSeed(seed);
// secretKey = "58e0254c211b858ef7896b00e3f36beeb13d568d47c6031c4218b87718061295884ab67f787b69e534bfdba8d5beb4e719700e90ac06317ed177d49e5a33be5a"
// publicKey = "884ab67f787b69e534bfdba8d5beb4e719700e90ac06317ed177d49e5a33be5a"
// Expected JWT for given payload
const expected = "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtvZEhad25lVlJTaHRhTGY4SktZa3hwREdwMXZHWm5wR21kQnBYOE0yZXh4SCIsInN1YiI6ImM0NzlmZTVkYzQ2NGU3NzFlNzhiMTkzZDIzOWE2NWI1OGQyNzhjYWQxYzM0YmZiMGI1NzE2ZTViYjUxNDkyOGUiLCJhdWQiOiJ3c3M6Ly9yZWxheS53YWxsZXRjb25uZWN0LmNvbSIsImlhdCI6MTY1NjkxMDA5NywiZXhwIjoxNjU2OTk2NDk3fQ.bAKl1swvwqqV_FgwvD4Bx3Yp987B9gTpZctyBviA-EkAuWc8iI8SyokOjkv9GJESgid4U8Tf2foCgrQp2qrxBA";
async function test() {
const jwt = await signJWT(sub, aud, ttl, keyPair);
console.log("jwt", jwt);
console.log("matches", jwt === expected);
const verified = await verifyJWT(jwt);
console.log("verified", verified);
}
test();