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
- Create an application in the dashboard. On its Overview page, copy the owner ID and the public key; both go into your software.
- Add a level and generate some license keys.
- In your software: call
init, thenregister(first time, with a license key) orlogin, thencheckwhenever you need to know the customer is still entitled. - 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_id | the application's owner ID |
| nonce | a fresh random string for every request: 16 to 64 characters from A-Z a-z 0-9 - _ |
| session | the 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)
nonceis the one you sent.sessionis the session value you sent, or empty forinit; forinituse thesessionfrom 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) > 60seconds, 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.
| Call | You send | Success returns |
|---|---|---|
| init | version | session, app, version |
| register | session, username, password, license, hwid | username, subscriptions |
| login | session, username, password, hwid | username, subscriptions |
| license | session (signed in), license | subscriptions |
| check | session (signed in) | username, subscriptions |
| var | session (signed in), name | name, value |
| file | session (signed in), name | name, size, sha256, content (base64) |
subscriptionsis a list of{level, name, expires}for the active ones;expiresis unix seconds, ornullfor lifetime.initfails withversionset to the current version if yours is out of date, and when the application is paused.registerconsumes the license key and signs the session in. Usernames are lowercasea-z 0-9 - _, 3 to 32 characters; the password needs at least 10 characters.loginfails if the subscription has expired, but the session stays signed in so you can calllicenseto redeem a new key. A banned account getsreason. It also fails when the account is at the developer's session limit and the developer chose to refuse extra sign-ins (see Sessions).hwidis 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.licenseextends a timed subscription of the same level; a lifetime key makes it permanent. Keys are case-insensitive.varandfilereturn what the developer stored under Data in the dashboard. They need an active subscription, and at least the level the developer set, if any. Checksha256against the decoded file.- A device or IP address on the developer's blacklist gets
Access denied.orThis 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:
loginfails with a message saying the account is already signed in on the maximum number of devices.
What your software must do:
- Call
checkevery 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
endedset toreplaced(the account signed in somewhere else) orrevoked(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
initthenlogin.
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.
| Call | Limit |
|---|---|
| init | 60 a minute |
| register | 10 an hour |
| login | 20 per 15 minutes, and 10 per account per 15 minutes |
| license | 30 an hour |
| check | 120 a minute |
| var | 120 a minute |
| file | 60 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