Skip to content
KeyAuth
Quick startRequestsVerifying repliesCallsSessionsLimitsSamples

Client API

Version 1.0. What your software sends to https://keys.ekisde.dev/api/1.0/ to check license keys, sign customers in and fetch their data.

Quick start

  1. Create an application in the dashboard. On its Overview page, copy the owner ID and the public key; both go into your software.
  2. Add a level and generate some license keys.
  3. In your software: call init, then register (first time, with a license key) or login, then check whenever you need to know the customer is still entitled.
  4. Verify the signature on every reply (below). An unsigned or wrongly signed reply is a failure, never a success.

Set your own User-Agent. KeyAuth sits behind Cloudflare, which rejects requests that carry a library's default agent (for example Python-urllib/3.x) with HTTP 403 and the plain text error code: 1010. That reply comes from Cloudflare, not from KeyAuth, so it is unsigned. Send something like MyApp/1.0 on every request.

Requests

POST https://keys.ekisde.dev/api/1.0/<call> with a JSON object. Every request has:

Field
owner_idthe application's owner ID
noncea fresh random string for every request: 16 to 64 characters from A-Z a-z 0-9 - _
sessionthe token returned by init (every call except init); a session lasts one hour

Verifying replies

The body is JSON with success (true or false) and message, plus fields specific to the call. The HTTP status is 200 when success is true and 400 when it is false, so always check success. Two headers come with every signed reply:

  • X-KeyAuth-Timestamp: unix seconds.
  • X-KeyAuth-Signature: base64 Ed25519 signature.

Build these bytes and check the signature with the application's public key (base64, 32 raw bytes):

message = nonce + "." + session + "." + timestamp + "." + (the raw reply body, byte for byte)
  • nonce is the one you sent. session is the session value you sent, or empty for init; for init use the session from the reply.
  • Use the body bytes exactly as received. They are canonical JSON (keys sorted, no spaces), so you never re-serialize anything.
  • Reject the reply if the signature is wrong, if abs(now - timestamp) > 60 seconds, or if a signature header is missing.
  • Treat every unsigned reply (unknown application, malformed request, HTTP 429, Cloudflare's 403) as a failure.

Because the nonce, session and timestamp are signed, a reply someone recorded earlier cannot be replayed as the answer to a new request.

Calls

Failures carry a human-readable message; do not match on its exact wording. The fields below are in addition to success and message.

CallYou sendSuccess returns
initversionsession, app, version
registersession, username, password, license, hwidusername, subscriptions
loginsession, username, password, hwidusername, subscriptions
licensesession (signed in), licensesubscriptions
checksession (signed in)username, subscriptions
varsession (signed in), namename, value
filesession (signed in), namename, size, sha256, content (base64)
  • subscriptions is a list of {level, name, expires} for the active ones; expires is unix seconds, or null for lifetime.
  • init fails with version set to the current version if yours is out of date, and when the application is paused.
  • register consumes the license key and signs the session in. Usernames are lowercase a-z 0-9 - _, 3 to 32 characters; the password needs at least 10 characters.
  • login fails if the subscription has expired, but the session stays signed in so you can call license to redeem a new key. A banned account gets reason. It also fails when the account is at the developer's session limit and the developer chose to refuse extra sign-ins (see Sessions).
  • hwid is required while the application has the device lock on. Send any stable identifier of the machine (hash it first if you prefer); the first login binds the device.
  • license extends a timed subscription of the same level; a lifetime key makes it permanent. Keys are case-insensitive.
  • var and file return what the developer stored under Data in the dashboard. They need an active subscription, and at least the level the developer set, if any. Check sha256 against the decoded file.
  • A device or IP address on the developer's blacklist gets Access denied. or This device is blocked.

Sessions and the concurrent-session limit

A developer can limit how many devices one customer may be signed in on at once, to stop account sharing. When a sign-in would go over the limit, the developer's setting decides:

  • Sign the oldest session out (the default): the new sign-in succeeds and the oldest active session of that account ends.
  • Refuse the new sign-in: login fails with a message saying the account is already signed in on the maximum number of devices.

What your software must do:

  • Call check every few minutes while it runs. A session only counts as active while it is used; one that stays silent past the developer's idle time stops counting, and so can be replaced.
  • A session that was ended early returns a failure with ended set to replaced (the account signed in somewhere else) or revoked (the developer signed it out). Show the message, stop the protected functionality, and let the user sign in again.
  • Sessions also end by themselves after one hour. Starting over means init then login.

Limits

Per IP address (and per account for sign-ins). Over the limit you get an unsigned HTTP 429, so back off and retry later.

CallLimit
init60 a minute
register10 an hour
login20 per 15 minutes, and 10 per account per 15 minutes
license30 an hour
check120 a minute
var120 a minute
file60 an hour

Request bodies are limited to 64 KB. Do not call check in a tight loop: once at start-up and then every few minutes is plenty.

Samples

Each one does the hard part, which is verifying the signature. Start from these.

Python reference_client.py

"""Reference client for the KeyAuth client API (see client-protocol.md). Needs `cryptography`.

    python reference_client.py https://keys.ekisde.dev OWNER_ID PUBLIC_KEY 1.0
"""

import base64
import json
import secrets
import sys
import time
import urllib.error
import urllib.request

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey


class KeyAuthError(Exception):
    pass


class Client:
    def __init__(self, base_url: str, owner_id: str, public_key_b64: str, version: str):
        self.base, self.owner_id, self.version = base_url.rstrip("/"), owner_id, version
        self.public_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key_b64))
        self.session = ""

    def call(self, name: str, **fields) -> dict:
        """One request. Raises KeyAuthError for anything unsigned, stale or badly signed; returns the verified body."""
        nonce = secrets.token_urlsafe(16)
        payload = {"owner_id": self.owner_id, "nonce": nonce, "session": self.session, **fields}
        request = urllib.request.Request(
            f"{self.base}/api/1.0/{name}", json.dumps(payload).encode(), {"Content-Type": "application/json", "User-Agent": "KeyAuth-Client/1.0"}  # Cloudflare blocks the default Python-urllib agent
        )
        try:
            response = urllib.request.urlopen(request, timeout=15)
        except urllib.error.HTTPError as error:  # 400 is a normal, signed failure
            response = error
        raw = response.read()
        timestamp, signature = response.headers.get("X-KeyAuth-Timestamp"), response.headers.get("X-KeyAuth-Signature")
        if not (timestamp and signature):
            raise KeyAuthError(f"unsigned response ({response.status}): {raw[:200]!r}")
        body = json.loads(raw)
        session = self.session or body.get("session", "")  # init has no session yet: it signs with the new one
        message = f"{nonce}.{session}.{timestamp}.".encode() + raw
        try:
            self.public_key.verify(base64.b64decode(signature), message)
        except (InvalidSignature, ValueError):
            raise KeyAuthError("bad signature") from None
        if abs(time.time() - int(timestamp)) > 60:
            raise KeyAuthError("stale response")
        return body

    def init(self) -> dict:
        body = self.call("init", version=self.version)
        if body["success"]:
            self.session = body["session"]
        return body


if __name__ == "__main__":
    base, owner, key, version = sys.argv[1:5]
    client = Client(base, owner, key, version)
    print(client.init())

Node.js keyauth.mjs

// KeyAuth client API sample for Node.js 18+ (no dependencies). See https://keys.ekisde.dev/docs/
//
//   import { KeyAuth } from "./keyauth.mjs";
//   const api = new KeyAuth({ baseUrl: "https://keys.ekisde.dev", ownerId: "YOUR_OWNER_ID", publicKey: "YOUR_PUBLIC_KEY", version: "1.0" });
//   await api.init();
//   const result = await api.call("login", { username: "ann", password: "a long password", hwid: "this-machine-id" });
//   if (result.success) console.log((await api.call("var", { name: "motd" })).value);

import { createPublicKey, randomBytes, verify } from "node:crypto";

// An Ed25519 public key is 32 raw bytes; Node wants it wrapped in this fixed DER header (SPKI).
const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");

/** True only if `signature` is the application's signature over nonce.session.timestamp.<exact body bytes>. */
export function verifyResponse(publicKeyB64, nonce, session, timestamp, signatureB64, rawBody) {
  const key = createPublicKey({ key: Buffer.concat([SPKI_PREFIX, Buffer.from(publicKeyB64, "base64")]), format: "der", type: "spki" });
  const message = Buffer.concat([Buffer.from(`${nonce}.${session}.${timestamp}.`), rawBody]);
  return verify(null, message, key, Buffer.from(signatureB64, "base64"));
}

export class KeyAuth {
  constructor({ baseUrl, ownerId, publicKey, version }) {
    Object.assign(this, { baseUrl: baseUrl.replace(/\/$/, ""), ownerId, publicKey, version, session: "" });
  }

  /** One API call. Throws unless the reply is signed correctly and fresh; returns the parsed body ({success, message, ...}). */
  async call(name, fields = {}) {
    const nonce = randomBytes(16).toString("base64url"); // a fresh random nonce for every request
    const response = await fetch(`${this.baseUrl}/api/1.0/${name}`, {
      method: "POST",
      headers: { "Content-Type": "application/json", "User-Agent": "MyApp/1.0" }, // set your own: Cloudflare blocks default agents
      body: JSON.stringify({ owner_id: this.ownerId, nonce, session: this.session, ...fields }),
    });
    const raw = Buffer.from(await response.arrayBuffer()); // keep the exact bytes: that is what was signed
    const timestamp = response.headers.get("x-keyauth-timestamp");
    const signature = response.headers.get("x-keyauth-signature");
    if (!timestamp || !signature) throw new Error(`unsigned reply (HTTP ${response.status}): treat as a failure`);
    const body = JSON.parse(raw.toString("utf8"));
    const signedSession = this.session || body.session || ""; // init has no session yet: it is signed with the new one
    if (!verifyResponse(this.publicKey, nonce, signedSession, timestamp, signature, raw)) throw new Error("bad signature");
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 60) throw new Error("stale reply");
    return body;
  }

  async init() {
    const body = await this.call("init", { version: this.version });
    if (body.success) this.session = body.session;
    return body;
  }
}

C++ (signature check with libsodium) keyauth_verify.hpp

// KeyAuth client API: verifying a signed reply in C++ with libsodium (https://libsodium.org). See https://keys.ekisde.dev/docs/
//
// Build with:  g++ -std=c++17 yourapp.cpp -lsodium
// Call sodium_init() once at startup. Use any HTTP and JSON libraries for the rest (libcurl and nlohmann/json are common).
//
// After each HTTP call you have: the exact response body bytes, and the X-KeyAuth-Timestamp and X-KeyAuth-Signature headers.
// Pass them here together with the nonce you sent and the session you sent ("" for `init`; then use the session in its reply).
#pragma once

#include <sodium.h>

#include <string>
#include <vector>

namespace keyauth {

// Standard base64 (with padding) -> bytes. Returns false if the text is not valid base64.
inline bool from_base64(const std::string& text, std::vector<unsigned char>& out) {
    out.assign(text.size(), 0);
    size_t length = 0;
    if (sodium_base642bin(out.data(), out.size(), text.c_str(), text.size(), nullptr, &length, nullptr, sodium_base64_VARIANT_ORIGINAL) != 0)
        return false;
    out.resize(length);
    return true;
}

// True only if `signature_b64` is the application's Ed25519 signature over  nonce + "." + session + "." + timestamp + "." + body.
inline bool verify_response(const std::string& public_key_b64, const std::string& nonce, const std::string& session,
                            const std::string& timestamp, const std::string& signature_b64, const std::string& raw_body) {
    std::vector<unsigned char> key, signature;
    if (!from_base64(public_key_b64, key) || key.size() != crypto_sign_PUBLICKEYBYTES) return false;
    if (!from_base64(signature_b64, signature) || signature.size() != crypto_sign_BYTES) return false;
    const std::string message = nonce + "." + session + "." + timestamp + "." + raw_body;  // the body exactly as received
    return crypto_sign_verify_detached(signature.data(), reinterpret_cast<const unsigned char*>(message.data()), message.size(), key.data()) == 0;
}

}  // namespace keyauth