SDKey

Docs

Sign in

License control

Session + sealed validate (optional HWID), version gate, bans, and end-user register/login/upgrade.

What you embed at build time

From the dashboard when you create an application:

  • API_BASE_URLhttps://api.sdkey.dev (no trailing slash)
  • APP_ID — application UUID
  • APP_VERSION — exact version string (must match app settings version)
  • APP_PUBLIC_KEY — Ed25519 public key (32 raw bytes as base64)

The private signing key never leaves the server. Clients only verify with the public key.

License validate flow

  1. POST /api/v1/session/init — establish an AES session (~15 minutes); send exact clientVersion
  2. POST /api/v1/licenses/validate — seal the license (+ optional HWID), verify the signed response

There is no separate activate, deactivate, or heartbeat endpoint. When HWID is provided and the app has HWID lock enabled (default), the first successful validate locks the license to that HWID; later validates must match. Omit HWID to skip lock, mismatch, and HWID-ban checks. Re-validate with a fresh nonce when you need another check.

1. Session init

POST https://api.sdkey.dev/api/v1/session/init
{
  "appId": "<uuid>",
  "clientNonceB64": "<base64 of 32 random bytes>",
  "clientVersion": "1.0.0"
}
Response
{
  "success": true,
  "sessionId": "<uuid>",
  "serverNonceB64": "...",
  "hkdfSaltB64": "...",
  "timestamp": 1720000000,
  "signatureB64": "...",
  "v": 1
}

clientVersion must exactly match the application's configured version; mismatch → APP_OUTDATED. Banned client IP → IP_BANNED. Disabled app → APP_DISABLED. Success has no message field. Failures use the plaintext error shape with customizable error text for those three codes:

Failure (plaintext)
{
  "success": false,
  "error": "Client version outdated",
  "code": "APP_OUTDATED"
}

Verify the Ed25519 signature over canonical JSON of { appId, hkdfSaltB64, serverNonceB64, sessionId, timestamp, v }, then derive the AES-256 session key via HKDF-SHA256. Rate limit: 60 / min / IP.

2. Sealed validate

Call POST https://api.sdkey.dev/api/v1/licenses/validate. Outer HTTPS body:

{
  "sessionId": "<uuid>",
  "ivB64": "...",
  "ciphertextB64": "...",
  "tagB64": "..."
}

Inner plaintext before AES-GCM seal (hwid optional):

{
  "hwid": "MACHINE-1",
  "licenseKey": "SDKY-XXXX-XXXX-XXXX-XXXX",
  "nonce": "<base64 ~16 bytes>",
  "timestamp": 1720000001,
  "v": 1
}

Response envelope adds signatureB64. After decrypt, both success and failure plaintext include a message string (editable per app for OK and most business failure codes). Success plaintext also includes integer subscriptionTier (default 0 on create):

Sealed success plaintext (after decrypt)
{
  "success": true,
  "code": "OK",
  "message": "validated",
  "status": "active",
  "expiresAt": "2026-01-01T00:00:00.000Z",
  "subscriptionTier": 0,
  "sessionId": "...",
  "timestamp": 1720000001,
  "v": 1
}
Sealed failure plaintext (after decrypt)
{
  "success": false,
  "code": "HWID_MISMATCH",
  "message": "Hardware ID mismatch",
  "status": null,
  "expiresAt": null,
  "sessionId": "...",
  "timestamp": 1720000001,
  "v": 1
}

Client order is mandatory:

  1. AES-GCM open → plaintext JSON
  2. Ed25519 verify with the app public key over canonical JSON
  3. Check sessionId and clock skew (±60s)
  4. Only then honor success / read message

Rate limit: 120 / min / IP. Use a fresh nonce every call (replay window ~120s).

Success and failure codes

Sealed path success: code: "OK" with message from the app's OK response message. Common sealed failures (often HTTP 200 with sealed body — read message inside the plaintext, not a top-level error):

SESSION_EXPIRED  CLOCK_SKEW  REPLAY
LICENSE_NOT_FOUND  APP_MISMATCH  BANNED  EXPIRED
HWID_MISMATCH  DECRYPT_FAIL  APP_DISABLED
APP_OUTDATED  HWID_BANNED  IP_BANNED

Customize strings via PATCH /api/v1/apps/:id/settings (responseMessages). With HWID lock on and HWID provided: first success binds HWID and starts duration if configured; mismatch later → HWID_MISMATCH.

End-user register / login / upgrade

Same sealed session as validate: open POST /session/init, then POST a sealed envelope to register / login / upgrade (rate 30 / min / IP). applicationId and version gating come from the crypto session — do not send appId or clientVersion in the inner body. Optional hwid follows the same skip-when-omitted rules. When CRYPTO_ENFORCE=true, plaintext bodies are rejected (CRYPTO_REQUIRED).

Outer envelope (same as validate):

{
  "sessionId": "<uuid>",
  "ivB64": "...",
  "ciphertextB64": "...",
  "tagB64": "..."
}
POST /api/v1/client/register (inner plaintext)
{
  "username": "player1",
  "password": "••••••••",
  "email": "[email protected]",
  "licenseKey": "SDKY-....",
  "hwid": "...",
  "nonce": "<base64 ~16 bytes>",
  "timestamp": 1720000001,
  "v": 1
}

If app setting requireLicenseToRegister is true (default), licenseKey is required (LICENSE_REQUIRED).

POST /api/v1/client/login (inner plaintext)
{
  "username": "player1",
  "password": "••••••••",
  "hwid": "...",
  "nonce": "<base64 ~16 bytes>",
  "timestamp": 1720000001,
  "v": 1
}
POST /api/v1/client/upgrade (inner; no password)
{
  "username": "player1",
  "licenseKey": "SDKY-....",
  "hwid": "...",
  "nonce": "<base64 ~16 bytes>",
  "timestamp": 1720000001,
  "v": 1
}

Upgrade attaches a usable key whose subscriptionTier is strictly greater than the user's current tier (no license → 0). Otherwise TIER_NOT_HIGHER.

Response envelope adds signatureB64. Client order matches validate: open → Ed25519 verify → sessionId + skew → then honor success. Both success and failure plaintext include message (not a top-level error).

Sealed success plaintext (after decrypt)
{
  "success": true,
  "code": "OK",
  "message": "ok",
  "sessionId": "...",
  "timestamp": 1720000001,
  "v": 1,
  "sessionToken": "<opaque>",
  "expiresAt": "…",
  "user": { "id": "…", "username": "player1", "email": null, "applicationId": "…" },
  "license": {
    "id": "…",
    "status": "active",
    "expiresAt": null,
    "subscriptionTier": 1
  },
  "session": { "ip": "…", "hwid": "…" }
}
Sealed failure plaintext (after decrypt)
{
  "success": false,
  "code": "INVALID_CREDENTIALS",
  "message": "Invalid username or password",
  "sessionId": "...",
  "timestamp": 1720000001,
  "v": 1
}

license may be null. Passwords are never echoed. Full wire notes: API reference → Client auth.

TypeScript client

Implement the wire protocol with the Web Crypto API, or use the in-repo @sdkey/sdk client (SdkeyClient) which covers session init, sealed validate (optional HWID), and sealed register / login / upgrade.

Sketch
const API_BASE_URL = 'https://api.sdkey.dev'
const APP_ID = '<uuid>'
const APP_VERSION = '1.0.0'
const APP_PUBLIC_KEY_B64 = '<base64>' // 32 raw Ed25519 bytes

async function validateLicense(licenseKey: string, hwid?: string) {
  // 1. Session init — include clientVersion: APP_VERSION
  const clientNonce = crypto.getRandomValues(new Uint8Array(32))
  const initRes = await fetch(API_BASE_URL + '/api/v1/session/init', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      appId: APP_ID,
      clientNonceB64: bytesToBase64(clientNonce),
      clientVersion: APP_VERSION,
    }),
  })
  const hello = await initRes.json()
  // Verify Ed25519 over canonical JSON of:
  // { appId, hkdfSaltB64, serverNonceB64, sessionId, timestamp, v }
  // Then HKDF-SHA256 → 32-byte AES session key (see derivation below).

  // 2. Sealed validate — omit hwid to skip HWID checks
  const inner = {
    ...(hwid !== undefined ? { hwid } : {}),
    licenseKey,
    nonce: bytesToBase64(crypto.getRandomValues(new Uint8Array(16))),
    timestamp: Math.floor(Date.now() / 1000),
    v: 1,
  }
  // AES-GCM seal(inner) → POST /api/v1/licenses/validate
  // Open envelope → verify Ed25519 → check sessionId + ±60s skew
  // Unlock only when success && code === "OK"
  // Read subscriptionTier from success plaintext
}

Session key derivation: IKM = clientNonce || serverNonce (32 + 32 bytes), salt = hkdfSaltB64, info = UTF-8 sdkey-session-v1 + appId, output 32 bytes. Canonical JSON for signatures: object keys sorted lexicographically, no insignificant whitespace.