Gyvar docs

Signing a request

Every Gyvar API request is signed with an Ed25519 keypair you generate. The cryptography is one call; the canonical string is what goes wrong.

Gyvar's server-to-server credential is an Ed25519 keypair you generate. You send us the public half; the private half never leaves your machine, not even at issuance. There is no shared secret to leak, nothing sensitive travels on the wire, and a breach of our key table hands an attacker a list of public keys.

The trade you accept: we cannot reissue a credential we never held. Rotation is register-a-second-key, then revoke the first - which is the correct rotation pattern anyway, because it has no downtime window.

The cryptography is one function call in every language below. The part that goes wrong is the canonical string. Read section 3 before section 4.


1. Generate a keypair

The easy way: gyvar-keygen

gyvar-keygen

One static binary, identical output on macOS, Linux and Windows. It prints the public key to upload and the private key to keep, and writes nothing to disk unless you pass --out.

It exists because there is no CLI command that does this on every platform:

preinstalledEd25519
macOSLibreSSL 3.3.6no - openssl genpkey -algorithm ed25519 answers Algorithm ed25519 not found
LinuxOpenSSL 1.1.1+ / 3.xyes
Windowsnothing by defaultonly if Git Bash or a package manager put OpenSSL there

ssh-keygen is on all three and does make Ed25519 keys, but it cannot export PKCS#8 (do_convert_to_pkcs8: unsupported key type ED25519) and stores the private half in a format that needs real parsing to reach the seed. So only Linux can do this in one line with what it already has.

The way around this on macOS

On macOS /usr/bin/openssl is LibreSSL and answers Algorithm ed25519 not found. Install OpenSSL and run it by path:

brew install openssl@3
$(brew --prefix openssl@3)/bin/openssl genpkey -algorithm ed25519 -out gyvar.pem

Installing it is not enough on its own: /usr/bin sits ahead of Homebrew on PATH, so bare openssl still resolves to LibreSSL and openssl version still prints it. Call the full path, or use gyvar-keygen, which has no such ambiguity.

Or generate it in your own language

import crypto from 'node:crypto';

const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');

// Upload this.
console.log(publicKey.export({ type: 'spki', format: 'pem' }));

// Keep this. 32 raw bytes, base64. Store it the way you store a database password.
const der = privateKey.export({ type: 'pkcs8', format: 'der' });
console.log(der.subarray(der.length - 32).toString('base64'));

Upload the public key in the dashboard under the project's API keys. PEM is the documented form; 32 base64-encoded bytes are also accepted.


2. The four headers

HeaderValue
X-Gyvar-Keyyour key id, gyv_k_live_... or gyv_k_sandbox_...
X-Gyvar-Timestampunix seconds, within 5 minutes of our clock
X-Gyvar-Nonce16-128 characters, fresh per request
X-Gyvar-Signaturestandard base64 of the Ed25519 signature

3. The canonical string

Six lines joined by \n (newline, no trailing newline):

<METHOD, uppercased>
<request target, path AND query, exactly as sent>
<lowercase hex sha256 of the raw request body>
<timestamp>
<nonce>
<key id>

Sign the UTF-8 bytes of that string. Base64 the 64-byte signature with the standard alphabet.

Nothing is normalised, on purpose

You sign the literal target you send, and we verify the literal target we received. Canonicalisation rules are where signing schemes go to die: every difference of opinion about parameter ordering, escaping or trailing slashes becomes a support ticket. So:

  • Do not re-sort query parameters. ?b=2&a=1 signs as ?b=2&a=1.
  • Do not add or strip a trailing slash.
  • Do not re-encode anything. If your HTTP client rewrites the URL, sign what it will actually send.

The six ways this goes wrong

  1. Hashing bytes you do not send. By far the most common. If you build a pretty-printed JSON string to hash and let your HTTP library re-serialize the object, the two differ by whitespace and every request fails signature_invalid. Build the body once, hash those bytes, send those bytes.

  2. Milliseconds instead of seconds. Date.now() and time.time() * 1000 are milliseconds. A millisecond timestamp is roughly the year 55000 and fails clock_skew.

  3. URL-safe base64. The signature uses the standard alphabet, with + and /.

  4. A 64-byte private key where a 32-byte seed is wanted. An Ed25519 private key is often stored as seed || public_key. Go's ed25519.PrivateKey is the 64-byte form; Node and Python want the 32-byte seed. Which end to cut depends on what you are holding, and the two are easy to confuse:

    You haveSizeThe seed is
    seed || public_key64 bytesthe first 32
    PKCS#8 DER, from a .pem file48 bytesthe last 32
  5. Encoding the seed twice. The seed you extracted is already base64. Running btoa(), base64, or Convert.ToBase64String on it again encodes the text of an encoding. This is the worst failure in this list because nothing catches it:

    btoa('MC4CAQAwBQYDK2VwBCIEIK+dX...')   // the PEM body, 64 base64 characters
    // -> decodes to 64 bytes of ASCII

    An Ed25519 PKCS#8 body is 64 base64 characters, so the doubly-encoded value decodes to exactly 64 bytes - a legal private key length. Every length check passes, your library signs without complaint, and you get a well-formed signature made with 64 bytes of ASCII text. The only symptom is signature_invalid, which points at the canonical string and not at the key.

    Sanity check before you debug anything else: base64 -d the value you are about to sign with, and confirm it is 32 bytes of binary, not 64 bytes of readable text.

  6. Reusing a nonce. We remember every nonce for twice the skew window, so a replayed request is refused with nonce_replayed even inside its timestamp window. Generate a fresh one per request - not per session, not per retry.

An empty body is not a special case: hash the empty string (e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855).

Check your work before you build anything

GET /v1/whoami exists for exactly this. It moves nothing and tells you which key authenticated. Get that returning 200 before you touch an endpoint that does something.

Every failure has its own details.code - clock_skew, bad_nonce, nonce_replayed, signature_invalid, ip_not_allowed, key_expired - because "invalid signature" for all of them costs somebody's on-call an evening. An ip_not_allowed response echoes the address we actually saw, which is the value you need to add.


4. Working code

import crypto from 'node:crypto';

const KEY_ID = process.env.GYVAR_KEY_ID;
const BASE = 'https://api.gyvar.com';

// Node wants the 32-byte seed in a PKCS#8 wrapper. The hex prefix is the fixed
// Ed25519 PKCS#8 header; only the seed after it changes.
const seed = Buffer.from(process.env.GYVAR_PRIVATE_KEY, 'base64').subarray(0, 32);
const privateKey = crypto.createPrivateKey({
  key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), seed]),
  format: 'der',
  type: 'pkcs8',
});

export async function gyvar(method, path, bodyObj) {
  // Build the body ONCE and both hash and send these exact bytes.
  const body = bodyObj === undefined ? '' : JSON.stringify(bodyObj);
  const bodyHash = crypto.createHash('sha256').update(body, 'utf8').digest('hex');
  const timestamp = String(Math.floor(Date.now() / 1000)); // seconds
  const nonce = crypto.randomBytes(16).toString('hex');

  const canonical = [method.toUpperCase(), path, bodyHash, timestamp, nonce, KEY_ID].join('\n');
  const signature = crypto.sign(null, Buffer.from(canonical, 'utf8'), privateKey).toString('base64');

  return fetch(BASE + path, {
    method,
    headers: {
      'X-Gyvar-Key': KEY_ID,
      'X-Gyvar-Timestamp': timestamp,
      'X-Gyvar-Nonce': nonce,
      'X-Gyvar-Signature': signature,
      ...(body ? { 'Content-Type': 'application/json' } : {}),
    },
    ...(body ? { body } : {}),
  });
}

await gyvar('GET', '/v1/whoami');
await gyvar('POST', '/v1/addresses', { chain: 'tron', reference: 'customer-123' });

5. The IP allowlist

Every key carries a mandatory IP allowlist, so a leaked private key is a failed request rather than a compromise. GET /v1/ip is unauthenticated on purpose - you need your egress address before you can create the key that requires it - and it resolves the address exactly the way the allowlist compares it, so what it reports is what will match.

When your egress changes, requests fail with ip_not_allowed and the response carries the address we saw. Add it in the dashboard.


6. Sandbox

A key belongs to exactly one mode, named in the key id itself. gyv_k_sandbox_... reaches sandbox data and a balance faucet; gyv_k_live_... reaches live data. A key cannot act in a universe it does not belong to, because its mode is a column on its own row rather than a parameter anyone can send.

Two caveats worth knowing before you plan around it:

  • Idempotency keys must never be shared across modes.
  • Bitcoin and Tron acceptance is simulated in sandbox - the sandbox cannot fulfil them, so we mock them. Every other chain reaches the sandbox for real. Tron is our first live corridor, so the chain you most need to rehearse is the one you cannot fully rehearse.