Authentication and signing

Every call in both directions carries an Ed25519 signature. There are no API keys, bearer tokens or shared secrets.

The keys

KeyWho holds the private halfWhat it signsHow the other side gets the public half
Operator keyyouyour calls to the operator APIyou upload it (PEM) when you register
RGS wallet keyusour calls to your walletyou download it (PEM and a SHA-256 fingerprint) and confirm the fingerprint out of band

Signing a request to the operator API

Send these headers:

HeaderValue
X-Operatoryour operator id (the slug you were given; distinct per environment)
X-Signaturestandard Base64 (with padding, 88 characters) of the Ed25519 signature
Content-Typeapplication/json on POST

There are two signing forms. Use the second one if you can.

Engine form: the body only

The signature covers the exact bytes of the request body. For a GET it covers the empty string. This is the form Engine uses, so an Engine-written client keeps working. It proves who sent the call but not when, and it does not cover the path or query, so the RGS only uses query parameters for read filters.

Full-request form (A9): method, path, query and a timestamp

Add the header RuneCode-Timestamp: <unix seconds> and sign this string (UTF-8):

METHOD "\n" PATH "\n" CANONICAL_QUERY "\n" TIMESTAMP "\n" BODY

The SDKs implement both; fullRequestSigning: true turns the second one on for the Engine endpoints too.

Serialize the JSON body once, sign those bytes, and send those same bytes. Re-serializing between signing and sending is the most common cause of 401.

Reference (Node, no libraries)

import { createPrivateKey, sign } from "node:crypto";
const key = createPrivateKey(process.env.OPERATOR_PRIVATE_KEY_PEM!);
const body = JSON.stringify(payload);                          // serialize ONCE
const signature = sign(null, Buffer.from(body), key).toString("base64"); // null = pure Ed25519, no pre-hash
// send `body` with X-Operator and X-Signature

Verifying a request from the RGS (your wallet)

The RGS sends x-operator (your slug) and x-signature on every wallet call except GET /health. The signature covers the raw body bytes.

  1. Read the raw body.
  2. Check x-operator is your slug; otherwise answer 401 ERR_ATE.
  3. Verify x-signature over the raw bytes against each RGS key (current and standby); accept if any verifies.
  4. Only then parse the JSON.

A signature proves origin, not freshness. Safety against replays comes from idempotency on the transaction ids, not from timestamps (see Implementing the wallet).

Test vectors

vectors/signing.json holds a test key and known answers: Ed25519 is deterministic, so the same body always gives the same signature. Every SDK must reproduce them exactly; use them to check your own implementation.

Common mistakes