Authentication and signing
Every call in both directions carries an Ed25519 signature. There are no API keys, bearer tokens or shared secrets.
The keys
| Key | Who holds the private half | What it signs | How the other side gets the public half |
|---|---|---|---|
| Operator key | you | your calls to the operator API | you upload it (PEM) when you register |
| RGS wallet key | us | our calls to your wallet | you download it (PEM and a SHA-256 fingerprint) and confirm the fingerprint out of band |
- Ed25519 only, as PEM: private keys in PKCS#8 (
-----BEGIN PRIVATE KEY-----), public keys as SPKI (-----BEGIN PUBLIC KEY-----). Both are whatopenssl genpkey -algorithm ed25519andopenssl pkey -puboutproduce. RSA is not accepted. - Two active keys per operator at any time, so you can rotate without downtime: a signature valid under either key is accepted.
- We always publish two RGS wallet keys,
currentandstandby. Your wallet must accept a signature from either, at all times. On a compromise we switch tostandbyimmediately with no action on your side. A planned rotation is announced at least 14 days ahead. - One key per purpose per environment. Never reuse a sandbox key in production.
Signing a request to the operator API
Send these headers:
| Header | Value |
|---|---|
X-Operator | your operator id (the slug you were given; distinct per environment) |
X-Signature | standard Base64 (with padding, 88 characters) of the Ed25519 signature |
Content-Type | application/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
PATHis the path as sent, without host.CANONICAL_QUERY: decode eachname=valuepair (+means space), sort by name then value (byte order), then re-encode with RFC 3986 (letters, digits and-._~stay literal, everything else is%XXin upper case) and join with&. Empty if there is no query.TIMESTAMPis the same text you send in the header, and must be within 60 seconds of our clock.- Required for every
/runecode/v1/*call. Optional but recommended on the Engine endpoints (/v1/*): when a request carriesRuneCode-Timestamp, the signature must be the full-request form. An operator can make it mandatory.
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.
- Read the raw body.
- Check
x-operatoris your slug; otherwise answer401 ERR_ATE. - Verify
x-signatureover the raw bytes against each RGS key (current and standby); accept if any verifies. - 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
- Signing a pretty-printed or re-ordered body, then sending a different serialization.
- Using the standard Base64 alphabet without padding, or URL-safe Base64. It is standard, with padding.
- Pre-hashing the body (Ed25519 here is the pure form, RFC 8032, no pre-hash).
- Verifying after parsing instead of before.
- Trusting only one RGS key, which breaks during an emergency rotation.