I run two daemons on different machines that talk through a WebSocket relay on a cheap VPS. The relay forwards messages between them, and I didn't want it reading any of that traffic. Partly principle, partly because if someone pops the box I don't want cleartext payloads sitting in memory or logs.
The whole thing ended up around ~160 lines of TypeScript. Ed25519 for identity, X25519 for key exchange, AES-256-GCM for the actual encryption. All node:crypto, zero external deps. Node 22.
Two key pairs (blame Node)
Ed25519 and X25519 are both Curve25519. Ed25519 signs. X25519 does Diffie-Hellman.
You can convert between them (libsodium has crypto_sign_ed25519_sk_to_curve25519). I tried. Node.js crypto doesn't expose that conversion. You'd need tweetnacl or libsodium-wrappers, and I was trying to keep deps at zero.
So: two key pairs. Ed25519 is the long-lived identity, persisted to disk. X25519 is ephemeral, generated per connection, never saved.
Persisting the identity
import * as crypto from "node:crypto";
import * as fs from "node:fs/promises";
interface IdentityKeys {
privateKey: crypto.KeyObject;
publicKeyHex: string;
}
async function loadOrCreateKeys(keysDir: string): Promise<IdentityKeys> {
const privPath = `${keysDir}/identity.key`;
const pubPath = `${keysDir}/identity.pub`;
try {
const privDer = await fs.readFile(privPath);
const pubHex = await fs.readFile(pubPath, "utf-8");
const privateKey = crypto.createPrivateKey({
key: privDer,
format: "der",
type: "pkcs8",
});
return { privateKey, publicKeyHex: pubHex.trim() };
} catch {
// first run, generate fresh
}
const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
const privDer = privateKey.export({ type: "pkcs8", format: "der" });
await fs.writeFile(privPath, privDer);
await fs.chmod(privPath, 0o600);
const pubSpki = publicKey.export({ type: "spki", format: "der" });
// 44 bytes come back. first 12 are ASN.1 header junk. real key is 12-43.
const publicKeyHex = pubSpki.subarray(12).toString("hex");
await fs.writeFile(pubPath, publicKeyHex);
return { privateKey, publicKeyHex };
}
The SPKI export cost me time. You ask for the public key, you get 44 bytes back instead of 32. Sat there running xxd and comparing against RFC 8032 test vectors until I realized the first 12 bytes are ASN.1 wrapping that Node just includes. Slicing the buffer is ugly but it works.
Signing:
function sign(privateKey: crypto.KeyObject, payload: unknown): string {
const message = Buffer.from(JSON.stringify(payload), "utf-8");
const signature = crypto.sign(null, message, privateKey);
return signature.toString("base64");
}
First arg to crypto.sign is the digest algorithm. Ed25519 needs null because the hash is built into the algorithm. I passed "sha256" the first time and got a throw with zero useful context.
ECDH
Each side generates a throwaway X25519 pair per connection:
interface KeyPair {
publicKey: Buffer;
privateKey: Buffer;
}
function generateKeyPair(): KeyPair {
const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519");
return {
publicKey: publicKey.export({ type: "spki", format: "der" }),
privateKey: privateKey.export({ type: "pkcs8", format: "der" }),
};
}
ECDH, then HKDF:
function deriveSessionKey(
localPrivateKey: Buffer,
remotePubKey: Buffer,
): Buffer {
const privKey = crypto.createPrivateKey({
key: localPrivateKey,
format: "der",
type: "pkcs8",
});
const pubKey = crypto.createPublicKey({
key: remotePubKey,
format: "der",
type: "spki",
});
const sharedSecret = crypto.diffieHellman({
privateKey: privKey,
publicKey: pubKey,
});
return Buffer.from(
crypto.hkdfSync("sha256", sharedSecret, "", "relay-e2e-v1", 32),
);
}
Don't skip the HKDF step. The raw ECDH output is a curve point, not uniformly random bytes. The info string ("relay-e2e-v1") just binds it to this protocol.
The actual encryption
function encrypt(sessionKey: Buffer, plaintext: string): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", sessionKey, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
// iv (12) + authTag (16) + ciphertext
return Buffer.concat([iv, authTag, encrypted]).toString("base64");
}
function decrypt(sessionKey: Buffer, encoded: string): string {
const data = Buffer.from(encoded, "base64");
const iv = data.subarray(0, 12);
const authTag = data.subarray(12, 28);
const ciphertext = data.subarray(28);
const decipher = crypto.createDecipheriv("aes-256-gcm", sessionKey, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]).toString("utf8");
}
IV + auth tag + ciphertext packed into one base64 string. Receiver knows the layout. No framing needed.
This error wasted my entire night:
Error: Unsupported state or unable to authenticate data
decipher.final() throws that when the auth tag doesn't match. I was convinced my ECDH was broken. Added console.log on both sides, dumped the shared secret hex, they were identical. Stared at the code for way too long.
Turned out I had .toString("base64") on the sender and .toString("base64url") on the receiver. One character difference in the output. The error message tells you nothing about what actually failed.
Over the wire
The relay is just a WebSocket server. Two peers join a room. First message each side sends is its X25519 public key, unencrypted:
// both sides send this on connect
ws.send(JSON.stringify({
type: "DATA",
room_id: roomId,
payload: JSON.stringify({
_type: "KEY_EXCHANGE",
pubkey: myKeyPair.publicKey.toString("base64"),
}),
}));
On the receiving end:
function handleData(roomId: string, payload: string): void {
const room = rooms.get(roomId);
if (!room) return;
try {
const parsed = JSON.parse(payload);
if (parsed._type === "KEY_EXCHANGE" && parsed.pubkey) {
const remotePub = Buffer.from(parsed.pubkey, "base64");
room.sessionKey = deriveSessionKey(myKeyPair.privateKey, remotePub);
return;
}
} catch {
// not JSON = encrypted payload
}
if (room.sessionKey) {
try {
const plaintext = decrypt(room.sessionKey, payload);
// handle decrypted message
} catch {
console.error("decrypt failed for room", roomId);
}
}
}
Yeah, JSON.parse failure as the encrypted/unencrypted discriminator. Not elegant. But the key exchange piggybacks on the relay's existing DATA message type, so I didn't have to touch the relay code at all.
DER vs PEM
I store keys as DER. Smaller, no base64 overhead, no -----BEGIN headers.
But pass DER bytes with format: "pem" and you get this:
error:0480006C:PEM routines::no start line
Googled that error, got a bunch of OpenSSL forum posts about certificate chains. Took me 20 minutes to realize I just had the format string wrong:
// this works
crypto.createPrivateKey({ key: derBuffer, format: "der", type: "pkcs8" });
// this doesn't - DER bytes but told Node to expect PEM
crypto.createPrivateKey({ key: derBuffer, format: "pem", type: "pkcs8" });
What this doesn't do
No forward secrecy beyond the session level. If someone records traffic and later grabs the X25519 private key from memory, they can decrypt that session. Real forward secrecy means key ratcheting like Signal does. Per-session ephemeral keys felt like enough for my case. They live in memory and die on disconnect.
The relay can also MITM the key exchange. It sees both X25519 public keys go through, could swap them and sit in the middle. The fix is signing the exchange with Ed25519. Haven't built it yet because I own the relay box. But I know that's a cop-out.
Not using GCM's AAD either, so replaying an encrypted message from one room into another would technically decrypt fine. Low priority when you control both sides.
All node:crypto. The API has gotten a lot less painful since Node 20, and on 22 everything just worked without having to fight KeyObject conversions.
Top comments (0)