Integrate licensing that fights back.
Licentry turns a licence key into a live, signed, device-bound session. This guide takes you from a raw key to a hardened client, and the contract on this page mirrors the live API exactly.
Throughout these docs, BASE is your Licentry API origin: the endpoint issued to your account, or your own hostname if you are on a dedicated server with a custom domain. Never hardcode secrets in a shipped client.
Quickstart
Four calls take you from key to a running, verifiable session. Everything else in this guide hardens what you build here. Pick your language above and every example on this page follows it.
Six rules that fail silently. Build these first.
Most of this protocol fails loudly. A strict body, a wrong seq, an expired token: each comes back as a status code you cannot miss and cannot ship past. The six rules below are the ones that fail silently. A client that gets one of them wrong passes every test you will ever run, ships, and protects nothing, for the life of the product. They are ordered by that failure mode rather than by the order your code runs in.
1. Pin the signing key at build time. Never fetch it at runtime. Bake in every key the JWKS returns, as a kid to key map. A client that fetches its trust anchor at startup passes every test forever and has verified nothing, because whoever owns the machine owns its DNS and its trust store: the fetch returns the attacker's key and every signature then verifies against it. Verification without pinning is rule 3 performed on the attacker's key.
2. An absent signature is a refusal. If X-Licentry-Sig is missing, stop, and do not read the body. There are real paths on which we send no signature header at all, and stripping a header costs an attacker nothing. A verify routine shaped like if (response.has(SIG)) { check it } is one line of plausible defensive code and it fails open on every stripped header, silently, forever. No signature, no licensed path.
3. Verify the signature correctly. Raw r||s, exactly 64 bytes, not DER. A two-sided timestamp check. The key selected by kid. And the signature covers the canonical string "status|ts|sha256hex(body)", not the body, which is the most common way to get this wrong. Full order of operations at verifying the server.
4. Compare the clientNonce echo. Send a fresh random value every call and reject the response if the echo is missing or different. The signature binds a response to its own body, not to your request, so without the echo one captured signed 200 replays forever against your own client.
5. Gate on the outcome, never on "it verified". A verified 403 is still a refusal. A helper that returns only the body invites if (verify(res)) run(), which turns an authentic refusal into an unlock. Verify, then branch on the status, then read a field, and no catch-all branch reaches the licensed path.
6. Build no oracle. Never map a refusal to a licence state in your UI, your logs or your exit codes, and never vary retry policy by reason. Activate answers one generic refusal on a padded delay so a stolen key list cannot be sorted into live and dead; a client that guesses at the reason hands that distinction back.
Before all six: a device hash that does not move. This one is a precondition rather than a rule, and it is the only item here whose failure lands on your customer instead of on you.
An unstable hash does not refuse the request. It revokes the session, records a device mismatch that the sharing evidence view reads, emits an event on your account, and answers 403 license_suspended. So a recipe that shifts between runs cuts a paying customer off mid-session and manufactures evidence that they were sharing their key.
It fails on the customer's machine after a reboot, a dock, a driver update or a change of power source. It does not fail on your second test run, which is exactly why it reaches production. Derive it from identifiers that do not move, emit it as identical lowercase hex on every call, and test across a reboot before you ship.
Two loud rules the samples also assume, listed here so nothing is missing rather than because they are subtle. Send x-licentry-build on every session route, and send deviceHash on heartbeat and refresh as well as activate: the bodies are strict, so {"seq":1} alone is a 400 on every beat. Both announce themselves the first time you get them wrong.
1 · Activate a licence
Send the licence key plus a stable 64-hex device hash. You get back a short-lived session.
IDEM=$(openssl rand -hex 16) # 16 random bytes from a cryptographic source
CN=$(openssl rand -hex 8) # fresh per request, never reused
# Send the SAME Idempotency-Key on every retry of this attempt, and forget it
# once the call succeeds. A retry under a new key burns another device slot, and
# the default cap is one, so the customer is locked out of their own product.
CODE=$(curl -sS -D act.hdr -o act.json -w '%{http_code}' \
-X POST "$BASE/v1/sess/activate" \
-H "Content-Type: application/json" \
-H "x-licentry-build: $BUILD_TOKEN" \
-H "x-licentry-protocol: 1" \
-H "Idempotency-Key: $IDEM" \
-d "$(jq -cn --arg n "$CN" '{
licenseKey: "aB3xK9m-7Qp2n-M4kL8z-Xy1Wq9r",
deviceHash: "9f2c41d8b7a6e35c0d419b8a72e6c15d3f8074ba9e2d6c1785af30b9e4c26a71",
clientNonce: $n
}')")
# The device hash is 64 hex characters, all of them. A shortened one is
# 400 Invalid request body, and the refusal does not name the field.
# Build it only from identifiers that read without elevation, so it is the same
# value whether or not the customer runs as administrator. A hash that moves
# between runs revokes the session with 403 license_suspended and records a
# hardware mismatch against the licence.
# licentry_accepted comes from the verify sample below. It refuses an unsigned
# response, a bad signature, a stale timestamp, a non-2xx status, and an echoed
# clientNonce that is not the one this call sent.
licentry_accepted "$CODE" act.hdr act.json "$CN" || exit 1
ACCESS_TOKEN=$(jq -r .accessToken act.json)
REFRESH_TOKEN=$(jq -r .refreshToken act.json)
SEQ=1; SERVER_NONCE="" # a new session starts its own nonce chain
#include <curl/curl.h>
static size_t sink(char* p, size_t sz, size_t n, void* o) {
static_cast<std::string*>(o)->append(p, sz * n);
return sz * n;
}
// hdrSink fills this from CURLOPT_HEADERFUNCTION. sig stays empty when the
// server sent no X-Licentry-Sig, and an empty sig is a refusal, not a skip: a
// product with vendor response signing on and an account key the server cannot
// load answers with no signature header at all.
struct SigHdr { std::vector<uint8_t> sig; std::string ts, kid; };
static size_t hdrSink(char* p, size_t sz, size_t n, void* o);
// idemKey is the caller's: 16 random bytes from a cryptographic source, made
// once per activation attempt and passed in unchanged on every retry of that
// attempt, then dropped. A retry under a new key burns another device slot, and
// the default cap is one.
bool activate(const Cfg& cfg, const std::string& deviceHash,
const std::string& idemKey, Session& out) {
const std::string clientNonce = randomHex(8); // fresh per request
std::string body = R"({"licenseKey":")" + cfg.licenseKey +
R"(","deviceHash":")" + deviceHash +
R"(","clientNonce":")" + clientNonce + R"("})";
std::string resp;
SigHdr hdr;
curl_slist* h = curl_slist_append(nullptr, "Content-Type: application/json");
h = curl_slist_append(h, ("x-licentry-build: " + cfg.buildToken).c_str());
h = curl_slist_append(h, "x-licentry-protocol: 1");
h = curl_slist_append(h, ("Idempotency-Key: " + idemKey).c_str());
CURL* c = curl_easy_init();
curl_easy_setopt(c, CURLOPT_URL, (cfg.base + "/v1/sess/activate").c_str());
curl_easy_setopt(c, CURLOPT_POSTFIELDS, body.c_str());
curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, sink);
curl_easy_setopt(c, CURLOPT_WRITEDATA, &resp);
curl_easy_setopt(c, CURLOPT_HEADERFUNCTION, hdrSink);
curl_easy_setopt(c, CURLOPT_HEADERDATA, &hdr);
curl_easy_perform(c);
long code = 0;
curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &code);
curl_slist_free_all(h);
curl_easy_cleanup(c);
// Gate on accepted, never on verifySig: a verified 403 is still a refusal.
if (!accepted(cfg.pinned(hdr.kid), (int)code, hdr.ts, resp, hdr.sig, time(nullptr)))
return false;
auto j = json::parse(resp, nullptr, false);
// The echo is the body field clientNonce, and it sits inside the signed
// material. Activate is the response carrying engineParams and the offline
// grace token, so an unchecked one is another machine's session replayed here.
if (j.is_discarded() || j.value("clientNonce", "") != clientNonce) return false;
out.accessToken = j["accessToken"];
out.refreshToken = j["refreshToken"];
out.seq = 1;
out.serverNonce.clear(); // a new session starts its own nonce chain
return true;
}
using System.Net.Http.Json;
using System.Text.Json;
record Session(string accessToken, string refreshToken, string expiresAt,
string refreshExpiresAt, long revocationVersion, JsonElement product,
bool idempotent, string offlineGraceJwt, string clientNonce);
// idempotencyKey is the caller's: 16 random bytes from a cryptographic source,
// made once per activation attempt and passed in unchanged on every retry of
// that attempt, then dropped. A retry under a new key burns another device slot,
// and the default cap is one.
async Task<Session> Activate(HttpClient http, string licenseKey, string deviceHash,
string buildToken, string idempotencyKey,
IReadOnlyDictionary<string, ECDsa> pinned) {
// Fresh per request and never reused. The server echoes it inside the
// signed body, which is the only thing separating a live answer from a
// recording of one.
var clientNonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(8));
var req = new HttpRequestMessage(HttpMethod.Post, "/v1/sess/activate") {
Content = JsonContent.Create(new { licenseKey, deviceHash, clientNonce })
};
req.Headers.Add("x-licentry-build", buildToken);
req.Headers.Add("x-licentry-protocol", "1");
req.Headers.Add("Idempotency-Key", idempotencyKey);
var res = await http.SendAsync(req);
var raw = await res.Content.ReadAsByteArrayAsync();
// Verify throws on a missing or bad signature, so this path fails closed.
// Then branch on the status it hands back: a verified 403 is still a refusal.
var v = Verify(res, raw, pinned);
if (v.Status != 201) throw new InvalidOperationException("activate refused");
var session = JsonSerializer.Deserialize<Session>(v.Body)!;
// The echo is the body field clientNonce. Activate is the response carrying
// engineParams and the offline grace token, so an unchecked one is another
// machine's session replayed onto this one.
if (session.clientNonce != clientNonce) {
throw new InvalidOperationException("nonce echo mismatch");
}
return session;
}
import secrets
import requests
# idem_key belongs to the caller: secrets.token_hex(16), made once per activation
# attempt and passed in unchanged on every retry of that attempt, then dropped.
# A retry under a new key burns another device slot, and the default cap is one,
# so the value has to outlive this function.
def activate(base, build_token, license_key, device_hash, idem_key, pinned):
client_nonce = secrets.token_hex(8) # fresh per request, never reused
r = requests.post(
f"{base}/v1/sess/activate",
headers={
"Content-Type": "application/json",
"x-licentry-build": build_token,
"x-licentry-protocol": "1",
"Idempotency-Key": idem_key,
},
json={"licenseKey": license_key, "deviceHash": device_hash,
"clientNonce": client_nonce},
timeout=10,
)
# verify raises on a missing or bad signature, so this fails closed. Branch
# on the status it hands back: a verified 403 is still a refusal.
status, session = verify(r, pinned)
if status != 201:
raise RuntimeError("activate refused")
# The echo is the body field clientNonce. Activate is the response carrying
# engineParams and the offline grace token, so an unchecked one is another
# machine's session replayed onto this one.
if session.get("clientNonce") != client_nonce:
raise RuntimeError("nonce echo mismatch")
return session
import { randomBytes } from "node:crypto";
// idemKey belongs to the caller: randomBytes(16).toString("hex"), made once per
// activation attempt and passed in unchanged on every retry of that attempt,
// then dropped. A retry under a new key burns another device slot, and the
// default cap is one, so the value has to outlive this call.
export async function activate({ base, buildToken, licenseKey, deviceHash,
idemKey, pinned }) {
const clientNonce = randomBytes(8).toString("hex"); // fresh per request, never reused
const res = await fetch(`${base}/v1/sess/activate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-licentry-build": buildToken,
"x-licentry-protocol": "1",
"Idempotency-Key": idemKey,
},
body: JSON.stringify({ licenseKey, deviceHash, clientNonce }),
});
// verifySigned throws on a missing or bad signature, so this fails closed.
// Branch on the status it hands back: a verified 403 is still a refusal.
const { status, body } = await verifySigned(res, pinned);
if (status !== 201) throw new Error("activate refused");
// The echo is the body field clientNonce. Activate is the response carrying
// engineParams and the offline grace token, so an unchecked one is another
// machine's session replayed onto this one.
if (body.clientNonce !== clientNonce) throw new Error("nonce echo mismatch");
return { ...body, seq: 1, serverNonce: "" };
}
type Session struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresAt string `json:"expiresAt"`
RefreshExpiresAt string `json:"refreshExpiresAt"`
RevocationVersion int64 `json:"revocationVersion"`
ClientNonce string `json:"clientNonce"`
}
// idemKey is the caller's: 16 random bytes, hex encoded, made once per
// activation attempt and passed in unchanged on every retry of that attempt,
// then dropped. A retry under a new key burns another device slot, and the
// default cap is one.
func Activate(base, build, key, deviceHash, idemKey string,
pinned map[string]*ecdsa.PublicKey) (*Session, error) {
cn := make([]byte, 8)
rand.Read(cn)
clientNonce := hex.EncodeToString(cn) // fresh per request, never reused
body, _ := json.Marshal(map[string]string{"licenseKey": key, "deviceHash": deviceHash,
"clientNonce": clientNonce})
req, _ := http.NewRequest("POST", base+"/v1/sess/activate", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-licentry-build", build)
req.Header.Set("x-licentry-protocol", "1")
req.Header.Set("Idempotency-Key", idemKey) // same key on every retry of this call
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
raw, err := io.ReadAll(res.Body) // the signature covers these exact bytes
if err != nil {
return nil, err
}
// Gate on Accepted, never on Verify: a verified 403 is still a refusal. A
// response carrying no X-Licentry-Sig fails here rather than slipping past.
if !Accepted(res, raw, pinned) {
return nil, fmt.Errorf("activate refused: %s", res.Status)
}
var s Session
if err := json.Unmarshal(raw, &s); err != nil {
return nil, err
}
// The echo is the body field clientNonce. Activate is the response carrying
// engineParams and the offline grace token, so an unchecked one is another
// machine's session replayed onto this one.
if s.ClientNonce != clientNonce {
return nil, errors.New("nonce echo mismatch")
}
return &s, nil
}
use serde_json::{json, Value};
// idem is the caller's: 16 random bytes, hex encoded, made once per activation
// attempt and passed in unchanged on every retry of that attempt, then dropped.
// A retry under a new key burns another device slot, and the default cap is one.
pub async fn activate(base: &str, build: &str, key: &str, device_hash: &str,
idem: &str, pinned: &HashMap<String, VerifyingKey>)
-> anyhow::Result<Value> {
// Fresh per request, never reused.
let client_nonce: String = (0..16)
.map(|_| char::from_digit(rand::random::<u32>() % 16, 16).unwrap())
.collect();
let res = reqwest::Client::new()
.post(format!("{base}/v1/sess/activate"))
.header("Content-Type", "application/json")
.header("x-licentry-build", build)
.header("x-licentry-protocol", "1")
.header("Idempotency-Key", idem) // same value on every retry of this activate
.json(&json!({ "licenseKey": key, "deviceHash": device_hash,
"clientNonce": client_nonce }))
.send()
.await?;
let status = res.status().as_u16();
let headers = res.headers().clone();
let raw = res.bytes().await?; // the signature covers these exact bytes
// One call refuses an unsigned response, a bad signature, a stale timestamp
// and a non-2xx status, so the question mark cannot turn a refusal into a
// green light.
verify(status, &headers, &raw, pinned)?;
let body: Value = serde_json::from_slice(&raw)?;
// The echo is the body field clientNonce. Activate is the response carrying
// engineParams and the offline grace token, so an unchecked one is another
// machine's session replayed onto this one.
if body.get("clientNonce").and_then(Value::as_str) != Some(client_nonce.as_str()) {
anyhow::bail!("nonce echo mismatch");
}
Ok(body)
}
HttpClient http = HttpClient.newHttpClient();
// idempotencyKey belongs to the caller: 16 random bytes, hex encoded, made once
// per activation attempt and sent unchanged on every retry of that attempt, then
// dropped. A retry under a new key burns another device slot, and the default
// cap is one.
byte[] nonceBytes = new byte[8];
SecureRandom.getInstanceStrong().nextBytes(nonceBytes);
String clientNonce = HexFormat.of().formatHex(nonceBytes); // fresh per request
String body = mapper.writeValueAsString(
Map.of("licenseKey", licenseKey, "deviceHash", deviceHash,
"clientNonce", clientNonce));
HttpRequest req = HttpRequest.newBuilder(URI.create(base + "/v1/sess/activate"))
.header("Content-Type", "application/json")
.header("x-licentry-build", buildToken)
.header("x-licentry-protocol", "1")
.header("Idempotency-Key", idempotencyKey) // same value on every retry
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
// The signature covers the raw bytes, so ask for bytes rather than a String.
HttpResponse<byte[]> res = http.send(req, HttpResponse.BodyHandlers.ofByteArray());
// Gate on accepted, never on verify: a verified 403 is still a refusal, and a
// response carrying no X-Licentry-Sig fails here rather than slipping past.
if (!accepted(res, PINNED)) {
throw new IllegalStateException("activate refused: " + res.statusCode());
}
Session session = mapper.readValue(res.body(), Session.class);
// The echo is the body field clientNonce. Activate is the response carrying
// engineParams and the offline grace token, so an unchecked one is another
// machine's session replayed onto this one.
if (!clientNonce.equals(session.clientNonce())) {
throw new IllegalStateException("nonce echo mismatch");
}
int seq = 1;
String serverNonce = ""; // a new session starts its own nonce chain
<?php
// $idemKey belongs to the caller: bin2hex(random_bytes(16)), made once per
// activation attempt and sent unchanged on every retry of that attempt, then
// dropped. A retry under a new key burns another device slot, and the default
// cap is one.
// $clientNonce is fresh per request and never reused. The server echoes it
// inside the signed body, and comparing the echo is what proves the answer is
// yours rather than a recording of somebody else's.
$clientNonce = bin2hex(random_bytes(8));
$body = json_encode(['licenseKey' => $licenseKey, 'deviceHash' => $deviceHash,
'clientNonce' => $clientNonce]);
$ch = curl_init($base . '/v1/sess/activate');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-licentry-build: ' . $buildToken,
'x-licentry-protocol: 1',
'Idempotency-Key: ' . $idemKey, // same value on every retry of this activate
],
]);
$h = [];
collectHeaders($ch, $h); // from the verify sample
$res = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
// Gate on licentryAccepted, never on licentryVerify: a verified 403 is still a
// refusal, and an answer carrying no X-Licentry-Sig is refused here.
if (!licentryAccepted($code, $h, $res, $pinned)) {
throw new RuntimeException('activate refused');
}
$session = json_decode($res, true);
// The echo is the body field clientNonce. Activate is the response carrying
// engineParams and the offline grace token, so an unchecked one is another
// machine's session replayed onto this one.
if (($session['clientNonce'] ?? null) !== $clientNonce) {
throw new RuntimeException('nonce echo mismatch');
}
struct Session: Decodable {
let accessToken: String, refreshToken: String
let expiresAt: String, refreshExpiresAt: String
let revocationVersion: Int
let clientNonce: String?
}
// idemKey belongs to the caller: 16 random bytes, hex encoded, made once per
// activation attempt and sent unchanged on every retry of that attempt, then
// dropped. A retry under a new key burns another device slot, and the default
// cap is one.
func activate(base: URL, build: String, key: String, deviceHash: String,
idemKey: String,
pinned: [String: P256.Signing.PublicKey]) async throws -> Session {
var req = URLRequest(url: base.appending(path: "v1/sess/activate"))
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue(build, forHTTPHeaderField: "x-licentry-build")
req.setValue("1", forHTTPHeaderField: "x-licentry-protocol")
req.setValue(idemKey, forHTTPHeaderField: "Idempotency-Key") // reuse on retry
var nonceBytes = [UInt8](repeating: 0, count: 8)
_ = SecRandomCopyBytes(kSecRandomDefault, nonceBytes.count, &nonceBytes)
let clientNonce = nonceBytes.map { String(format: "%02x", $0) }.joined()
req.httpBody = try JSONEncoder().encode(["licenseKey": key,
"deviceHash": deviceHash,
"clientNonce": clientNonce])
let (data, res) = try await URLSession.shared.data(for: req)
// Gate on accepted, never on verify: a verified 403 is still a refusal, and
// a response carrying no X-Licentry-Sig is refused inside rather than skipped.
guard let http = res as? HTTPURLResponse,
accepted(http, body: data, pinned: pinned) else {
throw LicentryError.activation("activate refused")
}
let session = try JSONDecoder().decode(Session.self, from: data)
// The echo is the body field clientNonce. Activate is the response carrying
// engineParams and the offline grace token, so an unchecked one is another
// machine's session replayed onto this one.
guard session.clientNonce == clientNonce else {
throw LicentryError.activation("nonce echo mismatch")
}
return session
}
val JSON = "application/json".toMediaType()
// idemKey belongs to the caller: 16 random bytes, hex encoded, made once per
// activation attempt and sent unchanged on every retry of that attempt, then
// dropped. A retry under a new key burns another device slot, and the default
// cap is one.
fun activate(base: String, build: String, key: String, deviceHash: String,
idemKey: String, pinned: Map<String, PublicKey>): Session {
val clientNonce = ByteArray(8).also { SecureRandom().nextBytes(it) }
.joinToString("") { "%02x".format(it) } // fresh per request
val body = JSONObject()
.put("licenseKey", key)
.put("deviceHash", deviceHash)
.put("clientNonce", clientNonce)
.toString()
val req = Request.Builder()
.url("$base/v1/sess/activate")
.addHeader("x-licentry-build", build)
.addHeader("x-licentry-protocol", "1")
.addHeader("Idempotency-Key", idemKey) // same value on every retry
.post(body.toRequestBody(JSON))
.build()
client.newCall(req).execute().use { res ->
val raw = res.body!!.bytes() // the signature covers these exact bytes
// Gate on accepted, never on verify: a verified 403 is still a refusal,
// and an answer carrying no X-Licentry-Sig is refused inside it.
check(accepted(res, raw, pinned)) { "activate refused" }
val session = gson.fromJson(String(raw), Session::class.java)
// The echo is the body field clientNonce. Activate is the response
// carrying engineParams and the offline grace token, so an unchecked one
// is another machine's session replayed onto this one.
check(session.clientNonce == clientNonce) { "nonce echo mismatch" }
return session
}
}
require "json"
require "net/http"
require "securerandom"
# idem_key belongs to the caller: SecureRandom.hex(16), made once per activation
# attempt and passed in unchanged on every retry of that attempt, then dropped.
# A retry under a new key burns another device slot, and the default cap is one,
# so the value has to outlive this method.
def activate(base, build_token, license_key, device_hash, idem_key, pinned)
client_nonce = SecureRandom.hex(8) # fresh per request, never reused
uri = URI("#{base}/v1/sess/activate")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["x-licentry-build"] = build_token
req["x-licentry-protocol"] = "1"
req["Idempotency-Key"] = idem_key
req.body = JSON.generate(licenseKey: license_key, deviceHash: device_hash,
clientNonce: client_nonce)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
# verify! raises on a missing or bad signature, so this fails closed. Branch on
# the status it hands back: a verified 403 is still a refusal.
status, session = verify!(res, res.body, pinned)
raise "activate refused" unless status == 201
# The echo is the body field clientNonce. Activate is the response carrying
# engineParams and the offline grace token, so an unchecked one is another
# machine's session replayed onto this one.
raise "nonce echo mismatch" unless session["clientNonce"] == client_nonce
session
end
{
"accessToken": "9af2c1...",
"refreshToken": "b7d3e0...",
"expiresAt": "2026-07-24T12:20:00Z",
"refreshExpiresAt": "2026-08-07T12:00:00Z",
"revocationVersion": 3,
"product": "your-product",
"idempotent": false,
"offlineGraceJwt": "eyJhbGciOiJFUzI1NiI..."
}
2 · Verify the response signature
Every response on the session routes is signed with ECDSA P-256. Verify it against a pinned public key before trusting a single field. That check is what stops a spoofed or MITM'd server.
X-Licentry-Sig: MEUCIQDr4k... # base64 raw r||s, 64 bytes
X-Licentry-Sig-Ts: 1753358400 # unix seconds
X-Licentry-Sig-Kid: rs1 # which pinned key verifies this
# canonical message you verify: "${status}|${ts}|${sha256hex(body)}"
# Two functions, because they answer two different questions.
# licentry_verify did the server really send this, and send it to me?
# licentry_accepted that, AND is the answer yes? A verified 403 is still a no.
# Both take: <status> <header file> <body file> <the clientNonce you sent>
licentry_verify() {
_code=$1; _hd=$2; _bd=$3; _cn=$4
hdr() { grep -i "^$1:" "$_hd" | tr -d '\r' | cut -d' ' -f2-; }
SIG=$(hdr X-Licentry-Sig); TS=$(hdr X-Licentry-Sig-Ts); KID=$(hdr X-Licentry-Sig-Kid)
# NO HEADER IS A REFUSAL, not a skip. A product with vendor response signing on
# and an account key the server cannot load sends no signature at all, and
# "check it when it is there" turns exactly that case into a free pass.
[ -n "$SIG" ] && [ -n "$TS" ] && [ -n "$KID" ] \
|| { echo "unsigned response, refusing" >&2; return 1; }
# TWO SIDED. "now - ts < 300" alone is defeated permanently by setting the
# system clock back: the difference goes negative, and negative is less than
# 300, so a signature of any age passes. Compare the absolute skew.
SKEW=$(( $(date +%s) - TS )); [ "$SKEW" -lt 0 ] && SKEW=$(( -SKEW ))
[ "$SKEW" -le 300 ] || { echo "signature timestamp outside tolerance" >&2; return 1; }
DIGEST=$(openssl dgst -sha256 -r "$_bd" | cut -d' ' -f1)
printf '%s|%s|%s' "$_code" "$TS" "$DIGEST" > canon.txt
# the header is raw r||s, openssl dgst only reads DER, so wrap it before verifying
printf %s "$SIG" | base64 -d | p1363-to-der > sig.der
openssl dgst -sha256 -verify "keys/$KID.pem" -signature sig.der canon.txt \
|| { echo "rejecting response" >&2; return 1; }
# A valid signature says the server sent this body. The echo says it sent it to
# YOU: a recording of someone else's 200 verifies just as well without this.
[ "$(jq -r .clientNonce "$_bd")" = "$_cn" ] \
|| { echo "nonce echo mismatch" >&2; return 1; }
}
# Gate on this one. A good signature only proves the server said this, and it
# signs its refusals too. The status is inside the signed material, so reading it
# here is reading something the server committed to.
licentry_accepted() {
licentry_verify "$@" || return 1
case "$1" in 2??) return 0 ;; esac
echo "server refused: HTTP $1" >&2
return 1
}
#include <openssl/ecdsa.h>
#include <openssl/evp.h>
#include <openssl/sha.h>
// pinned is the key named by X-Licentry-Sig-Kid; rs is the raw 64 byte r||s.
// A response with no X-Licentry-Sig arrives here as an empty rs and fails the
// size check below: no header is a refusal, not a skip.
bool verifySig(EVP_PKEY* pinned, int status, const std::string& ts,
const std::string& body, const std::vector<uint8_t>& rs, long now) {
// Two-sided: a rewound wall clock must not widen the replay window.
if (rs.size() != 64 || std::labs(now - std::stol(ts)) > 300) return false;
unsigned char d[32];
SHA256(reinterpret_cast<const unsigned char*>(body.data()), body.size(), d);
char hex[65];
for (int i = 0; i < 32; i++) sprintf(hex + i * 2, "%02x", d[i]);
std::string msg = std::to_string(status) + "|" + ts + "|" + std::string(hex, 64);
ECDSA_SIG* sig = ECDSA_SIG_new();
ECDSA_SIG_set0(sig, BN_bin2bn(rs.data(), 32, nullptr),
BN_bin2bn(rs.data() + 32, 32, nullptr));
unsigned char* der = nullptr; // OpenSSL verifies DER, not P1363
int derLen = i2d_ECDSA_SIG(sig, &der);
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, pinned);
bool ok = EVP_DigestVerify(ctx, der, derLen,
reinterpret_cast<const unsigned char*>(msg.data()),
msg.size()) == 1;
EVP_MD_CTX_free(ctx); OPENSSL_free(der); ECDSA_SIG_free(sig);
return ok;
}
// Call this, not verifySig, at the point where the answer decides anything.
// A signed 403 license_suspended verifies perfectly: the server really did say
// it. Treating "signature valid" as "licence valid" is the mistake that turns a
// correct implementation into an open door.
bool accepted(EVP_PKEY* pinned, int status, const std::string& ts,
const std::string& body, const std::vector<uint8_t>& rs, long now) {
if (!verifySig(pinned, status, ts, body, rs, now)) return false;
return status >= 200 && status < 300;
}
using System.Security.Cryptography;
using System.Text;
// pinned holds P-256 keys by kid; VerifyData reads the raw r||s, no DER wrapping
// Returns the STATUS alongside the body. A bool would read as "everything is
// fine", and a signed 403 license_suspended would satisfy it: the server really
// did sign that refusal. Callers must branch on Status, not on the call itself.
readonly record struct Verified(int Status, byte[] Body);
static Verified Verify(HttpResponseMessage res, byte[] rawBody,
IReadOnlyDictionary<string, ECDsa> pinned) {
// No X-Licentry-Sig at all is a REFUSAL, not a skip. A product with vendor
// response signing on and an account key the server cannot load answers with
// no signature header, and "check it when it is there" waves that through.
if (!res.Headers.TryGetValues("X-Licentry-Sig", out _))
throw new CryptographicException("unsigned response");
var ts = long.Parse(res.Headers.GetValues("X-Licentry-Sig-Ts").First());
// Two-sided: a rewound wall clock must not widen the replay window.
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300)
throw new CryptographicException("stale signature");
var kid = res.Headers.GetValues("X-Licentry-Sig-Kid").First();
if (!pinned.TryGetValue(kid, out var key))
throw new CryptographicException("unknown kid");
var sig = Convert.FromBase64String(res.Headers.GetValues("X-Licentry-Sig").First());
if (sig.Length != 64) throw new CryptographicException("expected raw r||s");
var digest = Convert.ToHexString(SHA256.HashData(rawBody)).ToLowerInvariant();
var msg = Encoding.UTF8.GetBytes($"{(int)res.StatusCode}|{ts}|{digest}");
if (!key.VerifyData(msg, sig, HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation))
throw new CryptographicException("bad signature");
return new Verified((int)res.StatusCode, rawBody);
}
import base64
import hashlib
import time
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec, utils
def verify(resp, pinned): # pinned: kid -> EllipticCurvePublicKey
# No X-Licentry-Sig at all is a REFUSAL, not a skip. A product with vendor
# response signing on and an account key the server cannot load answers with
# no signature header, and "check it when it is there" waves that through.
if "X-Licentry-Sig" not in resp.headers:
raise InvalidSignature("unsigned response")
ts = int(resp.headers["X-Licentry-Sig-Ts"])
key = pinned.get(resp.headers["X-Licentry-Sig-Kid"])
if key is None or abs(time.time() - ts) > 300:
raise InvalidSignature("unknown kid or stale timestamp")
raw = base64.b64decode(resp.headers["X-Licentry-Sig"])
if len(raw) != 64:
raise InvalidSignature("expected raw r||s")
der = utils.encode_dss_signature(int.from_bytes(raw[:32], "big"),
int.from_bytes(raw[32:], "big"))
digest = hashlib.sha256(resp.content).hexdigest()
msg = f"{resp.status_code}|{ts}|{digest}".encode()
key.verify(der, msg, ec.ECDSA(hashes.SHA256())) # raises, so callers fail closed
# Hand back the status too. Returning only the body invites
# "if verify(resp, pinned): run()", and a signed 403 license_suspended is a
# truthy dict: the server really did sign that refusal.
return resp.status_code, resp.json()
import { webcrypto as crypto } from "node:crypto";
// pinned: kid -> CryptoKey imported as ECDSA P-256; WebCrypto wants the raw r||s
export async function verifySigned(res, pinned) {
const raw = Buffer.from(await res.arrayBuffer());
// No x-licentry-sig at all is a REFUSAL, not a skip. A product with vendor
// response signing on and an account key the server cannot load answers with
// no signature header, and "check it when it is there" waves that through.
if (!res.headers.get("x-licentry-sig")) throw new Error("unsigned response");
const ts = Number(res.headers.get("x-licentry-sig-ts"));
const key = pinned.get(res.headers.get("x-licentry-sig-kid"));
if (!key || Math.abs(Date.now() / 1000 - ts) > 300) {
throw new Error("unknown kid or stale timestamp");
}
const sig = Buffer.from(res.headers.get("x-licentry-sig"), "base64");
const hash = await crypto.subtle.digest("SHA-256", raw);
const digest = Buffer.from(hash).toString("hex");
const msg = new TextEncoder().encode(`${res.status}|${ts}|${digest}`);
const ok = await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" },
key, sig, msg);
if (!ok) throw new Error("bad signature"); // fail closed, never use the body
// Return the STATUS too. A verified 403 license_suspended is still a refusal:
// a helper that hands back only the body invites "if (await verify(res)) run()".
return { status: res.status, body: JSON.parse(raw.toString("utf8")) };
}
// the header carries raw r||s, so split it and hand the halves to ecdsa.Verify
func Verify(res *http.Response, body []byte, pinned map[string]*ecdsa.PublicKey) bool {
ts, err := strconv.ParseInt(res.Header.Get("X-Licentry-Sig-Ts"), 10, 64)
// Two-sided: a rewound wall clock must not widen the replay window.
skew := time.Now().Unix() - ts
if err != nil || skew > 300 || skew < -300 {
return false
}
pub, ok := pinned[res.Header.Get("X-Licentry-Sig-Kid")]
if !ok {
return false
}
// No X-Licentry-Sig at all is a refusal, not a skip: a product with vendor
// response signing on and an account key the server cannot load answers with
// no signature header, and "check it when it is there" waves that through.
sigHdr := res.Header.Get("X-Licentry-Sig")
if sigHdr == "" {
return false
}
sig, err := base64.StdEncoding.DecodeString(sigHdr)
if err != nil || len(sig) != 64 {
return false
}
sum := sha256.Sum256(body)
msg := fmt.Sprintf("%d|%d|%x", res.StatusCode, ts, sum)
digest := sha256.Sum256([]byte(msg))
r := new(big.Int).SetBytes(sig[:32])
s := new(big.Int).SetBytes(sig[32:])
return ecdsa.Verify(pub, digest[:], r, s) // false means drop the response
}
// Accepted is what call sites should use. Verify answers "did the server sign
// this", which a 403 license_suspended also satisfies, so verifying alone is
// not permission to run. The status travels inside the signed material, so
// checking it here is checking something the server committed to.
func Accepted(res *http.Response, body []byte, pinned map[string]*ecdsa.PublicKey) bool {
if !Verify(res, body, pinned) {
return false
}
return res.StatusCode >= 200 && res.StatusCode < 300
}
use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey};
use sha2::{Digest, Sha256};
// Signature::from_slice takes the raw 64 byte r||s the header carries
pub fn verify(status: u16, headers: &HeaderMap, body: &[u8],
pinned: &HashMap<String, VerifyingKey>) -> Result<(), Error> {
// Indexing a HeaderMap panics when the header is absent, and a panic is not
// a refusal. No X-Licentry-Sig at all IS a refusal: a product with vendor
// response signing on and an account key the server cannot load answers with
// no signature header, and "check it when it is there" waves that through.
let sig_hdr = headers.get("x-licentry-sig").ok_or(Error::Unsigned)?;
let ts: i64 = headers.get("x-licentry-sig-ts").ok_or(Error::Unsigned)?
.to_str()?.parse()?;
if (Utc::now().timestamp() - ts).abs() > 300 {
return Err(Error::Stale);
}
let kid = headers.get("x-licentry-sig-kid").ok_or(Error::Unsigned)?.to_str()?;
let key = pinned.get(kid).ok_or(Error::UnknownKid)?;
let raw = BASE64.decode(sig_hdr.as_bytes())?;
let sig = Signature::from_slice(&raw)?;
let digest = hex::encode(Sha256::digest(body));
let msg = format!("{status}|{ts}|{digest}");
key.verify(msg.as_bytes(), &sig).map_err(|_| Error::BadSignature)?;
// Ok(()) here would mean "authentic", and callers read that as "allowed".
// A 403 license_suspended is authentic. Separate the two so the question
// mark operator cannot silently turn a refusal into a green light.
if !(200..300).contains(&status) {
return Err(Error::Refused(status));
}
Ok(())
}
// plain SHA256withECDSA parses DER; the P1363 variant takes the raw r||s directly
static boolean verify(HttpResponse<byte[]> res, Map<String, PublicKey> pinned)
throws Exception {
HttpHeaders h = res.headers();
// No X-Licentry-Sig at all is a REFUSAL, not a skip. A product with vendor
// response signing on and an account key the server cannot load answers with
// no signature header, and "check it when it is there" waves that through.
if (h.firstValue("X-Licentry-Sig").isEmpty()
|| h.firstValue("X-Licentry-Sig-Ts").isEmpty()) return false;
long ts = Long.parseLong(h.firstValue("X-Licentry-Sig-Ts").orElseThrow());
// Two-sided: a rewound wall clock must not widen the replay window.
if (Math.abs(Instant.now().getEpochSecond() - ts) > 300) return false;
PublicKey key = pinned.get(h.firstValue("X-Licentry-Sig-Kid").orElse(""));
if (key == null) return false;
byte[] rs = Base64.getDecoder()
.decode(h.firstValue("X-Licentry-Sig").orElseThrow());
if (rs.length != 64) return false;
String digest = HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(res.body()));
String msg = res.statusCode() + "|" + ts + "|" + digest;
Signature v = Signature.getInstance("SHA256withECDSAinP1363Format");
v.initVerify(key);
v.update(msg.getBytes(StandardCharsets.UTF_8));
return v.verify(rs); // a false here must reject the response
}
// Use this where the answer decides whether the product runs. verify() asks
// whether the server signed the response, and it signs its refusals too, so a
// 403 license_suspended passes it. The status is covered by the signature, so
// testing it here is testing something that cannot be edited in transit.
static boolean accepted(HttpResponse<byte[]> res, Map<String, PublicKey> pinned)
throws Exception {
if (!verify(res, pinned)) return false;
return res.statusCode() >= 200 && res.statusCode() < 300;
}
<?php
// Call this before curl_exec to capture the response headers, lowercased.
function collectHeaders($ch, array &$h): void
{
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, $line) use (&$h) {
$p = explode(':', $line, 2);
if (count($p) === 2) {
$h[strtolower(trim($p[0]))] = trim($p[1]);
}
return strlen($line);
});
}
// openssl_verify parses DER, the header is raw r||s, so wrap it first
function licentryVerify(int $status, array $h, string $body, array $pinned): bool
{
// No X-Licentry-Sig at all is a REFUSAL, not a skip. A product with vendor
// response signing on and an account key the server cannot load answers with
// no signature header, and "check it when it is there" waves that through.
if (!isset($h['x-licentry-sig'], $h['x-licentry-sig-ts'], $h['x-licentry-sig-kid'])) {
return false;
}
$ts = (int) $h['x-licentry-sig-ts'];
if (abs(time() - $ts) > 300) {
return false;
}
$pem = $pinned[$h['x-licentry-sig-kid']] ?? null;
if ($pem === null) {
return false;
}
$rs = base64_decode($h['x-licentry-sig'], true);
if ($rs === false || strlen($rs) !== 64) {
return false;
}
$der = derFromP1363($rs);
$msg = $status . '|' . $ts . '|' . hash('sha256', $body);
return openssl_verify($msg, $der, $pem, OPENSSL_ALGO_SHA256) === 1;
}
// Gate on this, not on licentryVerify. The server signs its refusals as well as
// its approvals, so a 403 license_suspended clears the signature check. The
// status is inside the signed material, which is why it is safe to trust here.
function licentryAccepted(int $status, array $h, string $body, array $pinned): bool
{
if (!licentryVerify($status, $h, $body, $pinned)) {
return false;
}
return $status >= 200 && $status < 300;
}
import CryptoKit
// ECDSASignature(rawRepresentation:) is exactly the r||s the header carries
func verify(_ res: HTTPURLResponse, body: Data,
pinned: [String: P256.Signing.PublicKey]) -> Bool {
func h(_ n: String) -> String? { res.value(forHTTPHeaderField: n) }
guard let tsText = h("X-Licentry-Sig-Ts"), let ts = TimeInterval(tsText),
let kid = h("X-Licentry-Sig-Kid"), let key = pinned[kid],
let sigText = h("X-Licentry-Sig"),
let raw = Data(base64Encoded: sigText), raw.count == 64,
abs(Date().timeIntervalSince1970 - ts) <= 300,
let sig = try? P256.Signing.ECDSASignature(rawRepresentation: raw)
// A missing header lands here and returns false: an unsigned response is a
// REFUSAL, not a skip. A product with vendor response signing on and an
// account key the server cannot load sends no signature header at all.
else { return false }
let digest = SHA256.hash(data: body).map { String(format: "%02x", $0) }.joined()
let msg = Data("\(res.statusCode)|\(Int(ts))|\(digest)".utf8)
return key.isValidSignature(sig, for: msg)
}
// Branch on this at the point of decision. verify answers "is this really from
// the server", and the server signs refusals too, so a 403 license_suspended
// returns true. The status is part of what was signed, so reading it here is
// reading a value the server committed to.
func accepted(_ res: HTTPURLResponse, body: Data,
pinned: [String: P256.Signing.PublicKey]) -> Bool {
guard verify(res, body: body, pinned: pinned) else { return false }
return (200..<300).contains(res.statusCode)
}
// SHA256withECDSA would want DER; the P1363 name verifies the raw r||s as sent
fun verify(res: Response, body: ByteArray, pinned: Map<String, PublicKey>): Boolean {
val ts = res.header("X-Licentry-Sig-Ts")?.toLongOrNull() ?: return false
// Two-sided: a rewound wall clock must not widen the replay window.
if (abs(Instant.now().epochSecond - ts) > 300) return false
val key = pinned[res.header("X-Licentry-Sig-Kid")] ?: return false
// No X-Licentry-Sig at all is a REFUSAL, not a skip. A product with vendor
// response signing on and an account key the server cannot load answers with
// no signature header, and "check it when it is there" waves that through.
val sigHdr = res.header("X-Licentry-Sig") ?: return false
val rs = Base64.getDecoder().decode(sigHdr)
if (rs.size != 64) return false
val digest = MessageDigest.getInstance("SHA-256").digest(body)
.joinToString("") { "%02x".format(it) }
val msg = listOf(res.code, ts, digest).joinToString("|")
val v = Signature.getInstance("SHA256withECDSAinP1363Format")
v.initVerify(key)
v.update(msg.toByteArray())
return v.verify(rs) // false means drop the response
}
// Gate on this. verify only says the bytes came from the server, and the server
// signs its refusals too, so a 403 license_suspended satisfies it. The status is
// covered by the signature, so it cannot have been rewritten in transit.
fun accepted(res: Response, body: ByteArray, pinned: Map<String, PublicKey>): Boolean {
if (!verify(res, body, pinned)) return false
return res.code in 200..299
}
require "openssl"
# the header is raw r||s, OpenSSL verifies DER, so rebuild the ASN.1 sequence
def verify!(res, body, pinned)
# No X-Licentry-Sig at all is a REFUSAL, not a skip. A product with vendor
# response signing on and an account key the server cannot load answers with
# no signature header, and "check it when it is there" waves that through.
raise "unsigned response" if res["X-Licentry-Sig"].nil?
ts = res["X-Licentry-Sig-Ts"].to_i
raise "stale signature" if (Time.now.to_i - ts).abs > 300
key = pinned.fetch(res["X-Licentry-Sig-Kid"])
rs = res["X-Licentry-Sig"].unpack1("m")
raise "expected raw r||s" unless rs.bytesize == 64
r = OpenSSL::BN.new(rs[0, 32].unpack1("H*"), 16)
s = OpenSSL::BN.new(rs[32, 32].unpack1("H*"), 16)
der = OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::Integer.new(r),
OpenSSL::ASN1::Integer.new(s)]).to_der
msg = "#{res.code}|#{ts}|#{OpenSSL::Digest::SHA256.hexdigest(body)}"
raise "signature rejected" unless key.verify(OpenSSL::Digest.new("SHA256"), der, msg)
# Return the status with the body. Handing back only the body reads as
# success at the call site, and a signed 403 license_suspended is a perfectly
# authentic refusal. The status is inside the signed material, so it is safe
# to act on once the signature checks out.
[res.code.to_i, JSON.parse(body)]
end
3 · Heartbeat on a timer
Before expiresAt, send a heartbeat with a strictly increasing sequence number. The first heartbeat after activate is always seq: 1, and each 200 returns the new expiresAt to schedule the next beat from.
# activate, refresh_session and stop_licensed_work are your own functions: the
# first two are the samples above, and each returns non-zero when refused.
SEQ=1 # back to 1 after activate and after every refresh
SERVER_NONCE="" # empty on the first beat, then whatever the server last sent
REACTIVATED=0
while :; do
CN=$(openssl rand -hex 8) # fresh every request, never reused
REQ=$(printf '{"seq":%d,"deviceHash":"%s","serverNonce":"%s","clientNonce":"%s"}' \
"$SEQ" "$DEVICE_HASH" "$SERVER_NONCE" "$CN")
CODE=$(curl -sS -D beat.hdr -o beat.json -w '%{http_code}' \
-X POST "$BASE/v1/sess/heartbeat" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "x-licentry-build: $BUILD_TOKEN" \
-H "x-licentry-protocol: 1" \
-d "$REQ")
# 429 and 5xx decide nothing about the licence and need not come from the app
# at all, so they are handled before verification. Back off and resend the
# IDENTICAL body: same SEQ, same SERVER_NONCE, same request.
case "$CODE" in 429|5??) sleep 30; continue ;; esac
# Everything below decides whether the product keeps running, so verify it
# before reading a single field out of the body.
licentry_verify "$CODE" beat.hdr beat.json "$CN" || exit 1
ERR=$(jq -r '.error // ""' beat.json)
case "$CODE" in
200) ;;
403|426) echo "terminal: $ERR" >&2; exit 1 ;; # killed, suspended, build retired
409) refresh_session || exit 1; SEQ=1; continue ;; # the nonce chain carries over
401)
case "$ERR" in
invalid_session) # evicted or gone. Activate ONCE, after a backoff.
[ "$REACTIVATED" = 1 ] && { echo "activation did not hold" >&2; exit 1; }
REACTIVATED=1; sleep 5
activate || exit 1; SEQ=1; SERVER_NONCE=""; continue ;;
expired) # refresh; activate only if the refresh is refused too
refresh_session || { activate || exit 1; SERVER_NONCE=""; }
SEQ=1; continue ;;
revoked) # terminal. Stop, and point the user at their seller.
echo "licence revoked" >&2; exit 1 ;;
stale_revocation) # stop the licensed work first, then honour the answer
stop_licensed_work
activate || exit 1; SEQ=1; SERVER_NONCE=""; continue ;;
nonce_mismatch) # a refresh cannot repair the chain, only a new session
activate || exit 1; SEQ=1; SERVER_NONCE=""; continue ;;
dpop_*) # dpop_required and every other dpop_ code
# Mint a FRESH proof and repeat this beat unchanged: same SEQ, same
# SERVER_NONCE. Activating again spends a device slot on something a
# new proof fixes on its own.
continue ;;
*) # a bare sentence with no code: the Authorization
# header is missing or blank. That is a bug in this script, and
# another activation will not fix it.
echo "fix the Authorization header: $ERR" >&2; exit 1 ;;
esac ;;
*) echo "permanent: HTTP $CODE $ERR" >&2; exit 1 ;; # a 400 never clears on a timer
esac
SERVER_NONCE=$(jq -r .serverNonce beat.json)
EXP=$(jq -r .expiresAt beat.json)
SEQ=$((SEQ + 1))
sleep $(( ($(date -d "$EXP" +%s) - $(date +%s)) / 2 ))
done
// Returns the HTTP status and fills out and hdr. 200 on its own is not
// permission to run: the caller verifies before it reads a field.
long heartbeat(CURL* c, const Cfg& cfg, const std::string& base,
const std::string& access, int seq, const std::string& deviceHash,
const std::string& serverNonce, const std::string& clientNonce,
std::string& out, SigHdr& hdr) {
// deviceHash is REQUIRED and the body is strict: omit it and every beat is 400.
// serverNonce is the value from the previous beat, empty string on the first.
std::string body = R"({"seq":)" + std::to_string(seq) +
R"(,"deviceHash":")" + deviceHash +
R"(","serverNonce":")" + serverNonce +
R"(","clientNonce":")" + clientNonce + R"("})";
curl_slist* h = curl_slist_append(nullptr, "Content-Type: application/json");
h = curl_slist_append(h, ("Authorization: Bearer " + access).c_str());
h = curl_slist_append(h, ("x-licentry-build: " + cfg.buildToken).c_str());
h = curl_slist_append(h, "x-licentry-protocol: 1");
// add "DPoP: <proof>" here when the session is DPoP bound
curl_easy_setopt(c, CURLOPT_URL, (base + "/v1/sess/heartbeat").c_str());
curl_easy_setopt(c, CURLOPT_POSTFIELDS, body.c_str());
curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, sink);
curl_easy_setopt(c, CURLOPT_WRITEDATA, &out);
curl_easy_setopt(c, CURLOPT_HEADERFUNCTION, hdrSink);
curl_easy_setopt(c, CURLOPT_HEADERDATA, &hdr);
curl_easy_perform(c);
long code = 0;
curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &code);
curl_slist_free_all(h);
return code;
}
// The loop. Each refusal below wants a different answer, and re-activating on
// all of them spends the device slot on problems a refresh would have fixed.
void runSession(CURL* c, const Cfg& cfg, Session& s) {
bool reactivated = false;
// One idempotency key per activation attempt; reuse it only if THAT attempt is
// retried after a dropped response.
auto reactivate = [&] { return activate(cfg, s.deviceHash, randomHex(16), s); };
for (;;) {
const std::string cn = randomHex(8);
std::string out;
SigHdr hdr;
long code = heartbeat(c, cfg, cfg.base, s.accessToken, s.seq, s.deviceHash,
s.serverNonce, cn, out, hdr);
// Not licence decisions, and they need not come from the app at all: back
// off and resend the IDENTICAL body, same seq and same serverNonce.
if (code == 429 || code >= 500) { sleepSeconds(30); continue; }
// Verify before reading a single field. An empty hdr.sig fails here.
if (!verifySig(cfg.pinned(hdr.kid), (int)code, hdr.ts, out, hdr.sig, time(nullptr)))
return stopLicensedWork();
auto j = json::parse(out, nullptr, false);
if (j.is_discarded() || j.value("clientNonce", "") != cn) return stopLicensedWork();
const std::string err = j.value("error", "");
if (code == 403 || code == 426) return stopLicensedWork(); // killed, suspended, retired
if (code == 409) { // seq out of step
if (!refreshSession(c, cfg, cfg.base, s.refreshToken, s.deviceHash, s))
return stopLicensedWork();
continue; // refresh sets seq to 1; the chain carries over
}
if (code == 401) {
if (err == "invalid_session") { // evicted or gone: activate ONCE, after a backoff
if (reactivated) return stopLicensedWork();
reactivated = true;
sleepSeconds(5);
if (!reactivate()) return stopLicensedWork();
continue;
}
if (err == "expired") { // refresh, and activate only if that is refused too
if (!refreshSession(c, cfg, cfg.base, s.refreshToken, s.deviceHash, s) &&
!reactivate()) return stopLicensedWork();
continue;
}
if (err == "revoked") return stopLicensedWork(); // terminal, do not loop
if (err == "stale_revocation") { // stop the licensed work, then honour the answer
stopLicensedWork();
if (!reactivate()) return;
continue;
}
if (err == "nonce_mismatch") { // only a new session repairs the chain
if (!reactivate()) return stopLicensedWork();
continue;
}
// dpop_required and every other dpop_ code: mint a FRESH proof and repeat
// this beat unchanged, same seq and same serverNonce.
if (err.rfind("dpop_", 0) == 0) continue;
// A bare sentence with no code: the Authorization header is missing or
// blank. That is a bug here, and another activation will not fix it.
return stopLicensedWork();
}
if (code != 200) return stopLicensedWork(); // 400 and friends never clear on a timer
s.serverNonce = j.value("serverNonce", "");
s.seq++;
sleepUntilHalfOf(j.value("expiresAt", ""));
}
}
record Beat(bool ok, string expiresAt, long revocationVersion,
string serverNonce, string clientNonce, string error);
int seq = 1; // 1 after activate and after every refresh
string serverNonce = ""; // empty on the first beat
bool reactivated = false;
while (running) {
var clientNonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(8));
var req = new HttpRequestMessage(HttpMethod.Post, "/v1/sess/heartbeat") {
Content = JsonContent.Create(new {
seq, deviceHash, serverNonce, clientNonce
})
};
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
req.Headers.Add("x-licentry-build", buildToken);
req.Headers.Add("x-licentry-protocol", "1");
var res = await http.SendAsync(req);
var raw = await res.Content.ReadAsByteArrayAsync();
var code = (int)res.StatusCode;
// Not licence decisions, and they need not come from the app at all. Back
// off and resend the IDENTICAL body: same seq, same serverNonce.
if (code == 429 || code >= 500) { await Task.Delay(30_000); continue; }
// Everything below decides whether the product keeps running, so verify
// before reading a field. Verify throws on an unsigned or forged response.
var v = Verify(res, raw, pinned);
var beat = JsonSerializer.Deserialize<Beat>(v.Body)!;
if (beat.clientNonce != clientNonce) { Shutdown(); return; }
// Terminal. An operator kill, a suspend or a retired build all land here,
// and treating them as "try again later" is how a revoked licence keeps
// running: blocking the API host would become the whole bypass.
if (code == 403 || code == 426) { Shutdown(); return; }
if (code == 409) { // seq out of step
if (await Refresh() is null) { Shutdown(); return; }
seq = 1; continue; // the nonce chain carries over
}
if (code == 401) {
switch (beat.error) {
case "invalid_session": // evicted or gone: activate ONCE, after a backoff
if (reactivated) { Shutdown(); return; }
reactivated = true;
await Task.Delay(5_000);
await Activate(); seq = 1; serverNonce = ""; continue;
case "expired": // refresh, and activate only if that is refused too
if (await Refresh() is null) { await Activate(); serverNonce = ""; }
seq = 1; continue;
case "revoked": // terminal. Do not loop.
Shutdown(); return;
case "stale_revocation": // stop the licensed work, then honour the answer
StopLicensedWork();
await Activate(); seq = 1; serverNonce = ""; continue;
case "nonce_mismatch": // only a new session repairs the chain
await Activate(); seq = 1; serverNonce = ""; continue;
default:
// dpop_required and every other dpop_ code: mint a FRESH proof
// and repeat this beat unchanged, same seq and same serverNonce.
if (beat.error?.StartsWith("dpop_") == true) continue;
// A bare sentence with no code: the Authorization header is
// missing or blank. A bug here, and activating cannot fix it.
Shutdown(); return;
}
}
if (code != 200) { Shutdown(); return; } // 400 and friends never clear on a timer
serverNonce = beat.serverNonce;
seq++;
await Task.Delay(HalfOf(beat.expiresAt));
}
seq = 1 # 1 after activate and after every refresh
server_nonce = "" # empty on the first beat
reactivated = False
while running:
client_nonce = secrets.token_hex(8)
r = requests.post(
f"{BASE}/v1/sess/heartbeat",
headers={"Authorization": f"Bearer {access_token}",
"x-licentry-build": BUILD_TOKEN, "x-licentry-protocol": "1"},
json={"seq": seq, "deviceHash": device_hash,
"serverNonce": server_nonce, "clientNonce": client_nonce},
timeout=10,
)
# Not licence decisions, and they need not come from the app at all. Back off
# and resend the IDENTICAL body: same seq, same server_nonce.
if r.status_code == 429 or r.status_code >= 500:
time.sleep(30); continue
# Everything below decides whether the product keeps running, so verify
# before reading a field. verify raises on an unsigned or forged response.
status, beat = verify(r, PINNED)
if beat.get("clientNonce") != client_nonce:
shutdown(); break # not an answer to this request
# Terminal: killed, suspended, or the build was retired. Do not fall through
# to a retry, and do not read the body for a value that is not there.
if status in (403, 426):
shutdown(); break
if status == 409: # seq out of step
if not refresh():
shutdown(); break
seq = 1; continue # the nonce chain carries over
if status == 401:
err = beat.get("error", "")
if err == "invalid_session": # evicted or gone: activate ONCE, after a backoff
if reactivated:
shutdown(); break
reactivated = True
time.sleep(5)
activate(); seq = 1; server_nonce = ""; continue
if err == "expired": # refresh, and activate only if that is refused too
if not refresh():
activate(); server_nonce = ""
seq = 1; continue
if err == "revoked": # terminal. Do not loop.
shutdown(); break
if err == "stale_revocation": # stop the licensed work, then honour the answer
stop_licensed_work()
activate(); seq = 1; server_nonce = ""; continue
if err == "nonce_mismatch": # only a new session repairs the chain
activate(); seq = 1; server_nonce = ""; continue
if err.startswith("dpop_"):
# dpop_required and every other dpop_ code: mint a FRESH proof and
# repeat this beat unchanged, same seq and same server_nonce.
continue
# A bare sentence with no code: the Authorization header is missing or
# blank. That is a bug here, and activating again will not fix it.
shutdown(); break
if status != 200: # 400 and friends never clear on a timer
shutdown(); break
server_nonce = beat.get("serverNonce", "")
seq += 1
time.sleep(half_of(beat["expiresAt"]))
let seq = 1; // 1 after activate and after every refresh
let serverNonce = ''; // empty on the first beat
let reactivated = false;
async function beat() {
const clientNonce = randomBytes(8).toString('hex');
const res = await fetch(`${BASE}/v1/sess/heartbeat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
'x-licentry-build': BUILD_TOKEN,
'x-licentry-protocol': '1',
},
body: JSON.stringify({ seq, deviceHash, serverNonce, clientNonce }),
});
// Not licence decisions, and they need not come from the app at all. Back off
// and resend the IDENTICAL body: same seq, same serverNonce.
if (res.status === 429 || res.status >= 500) return schedule(30_000);
// Everything below decides whether the product keeps running, so verify
// first. verifySigned throws on an unsigned or forged response.
const { status, body: b } = await verifySigned(res, pinned);
if (b.clientNonce !== clientNonce) return shutdown();
// Terminal. Anything that reschedules here turns an operator kill into a
// retry loop, which is exactly what the kill is meant to stop.
if (status === 403 || status === 426) return shutdown();
if (status === 409) { // seq out of step
if (!(await refresh())) return shutdown();
seq = 1; // the nonce chain carries over
return schedule(0);
}
if (status === 401) {
const err = b.error ?? '';
if (err === 'invalid_session') { // evicted or gone: activate ONCE
if (reactivated) return shutdown();
reactivated = true;
await new Promise((r) => setTimeout(r, 5_000)); // back off first
await activate();
seq = 1; serverNonce = '';
return schedule(0);
}
if (err === 'expired') { // refresh, activate only if that fails too
if (!(await refresh())) { await activate(); serverNonce = ''; }
seq = 1;
return schedule(0);
}
if (err === 'revoked') return shutdown(); // terminal. Do not loop.
if (err === 'stale_revocation') { // stop the work, then honour the answer
stopLicensedWork();
await activate();
seq = 1; serverNonce = '';
return schedule(0);
}
if (err === 'nonce_mismatch') { // only a new session repairs the chain
await activate();
seq = 1; serverNonce = '';
return schedule(0);
}
// dpop_required and every other dpop_ code: mint a FRESH proof and repeat
// this beat unchanged, same seq and same serverNonce.
if (err.startsWith('dpop_')) return schedule(0);
// A bare sentence with no code: the Authorization header is missing or
// blank. That is a bug here, and activating again will not fix it.
return shutdown();
}
if (status !== 200) return shutdown(); // 400 and friends never clear on a timer
serverNonce = b.serverNonce ?? '';
seq += 1;
schedule(halfOf(b.expiresAt));
}
type beatResp struct {
OK bool `json:"ok"`
ExpiresAt string `json:"expiresAt"`
RevocationVersion int64 `json:"revocationVersion"`
ServerNonce string `json:"serverNonce"`
ClientNonce string `json:"clientNonce"`
Error string `json:"error"`
}
// beat classifies the answer. Each sentinel wants a different response from the
// caller, and re-activating on all of them spends the single device slot on
// problems a refresh would have fixed.
func beat(seq int, serverNonce string) (*beatResp, error) {
cn := make([]byte, 8)
rand.Read(cn)
clientNonce := hex.EncodeToString(cn)
body, _ := json.Marshal(map[string]any{
"seq": seq, "deviceHash": deviceHash,
"serverNonce": serverNonce, "clientNonce": clientNonce,
})
req, _ := http.NewRequest("POST", base+"/v1/sess/heartbeat", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("x-licentry-build", buildToken)
req.Header.Set("x-licentry-protocol", "1")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, ErrOffline // network: your offline policy, not a licence decision
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body) // the signature covers these exact bytes
// Not a licence decision, and need not come from the app at all.
if res.StatusCode == 429 || res.StatusCode >= 500 {
return nil, ErrOffline
}
// Everything below decides whether the product keeps running, so verify
// before reading a field. A missing X-Licentry-Sig fails here.
if !Verify(res, raw, pinned) {
return nil, ErrTerminal
}
var b beatResp
if err := json.Unmarshal(raw, &b); err != nil {
return nil, ErrTerminal
}
if b.ClientNonce != clientNonce {
return nil, ErrTerminal // answer to a different request, or a replay
}
switch res.StatusCode {
case 200:
return &b, nil
case 403, 426:
return nil, ErrTerminal // killed, suspended, build retired: stop
case 409:
return nil, ErrRefreshFirst // seq out of step
case 401:
break
default:
return nil, ErrPermanent // a 400 never clears on a timer
}
switch b.Error {
case "invalid_session":
return nil, ErrReactivateOnce
case "expired":
return nil, ErrRefreshFirst
case "revoked":
return nil, ErrTerminal
case "stale_revocation":
return nil, ErrStaleRevocation
case "nonce_mismatch":
return nil, ErrReactivateOnce // a refresh cannot repair the chain
}
if strings.HasPrefix(b.Error, "dpop_") {
return nil, ErrSameBeatAgain
}
return nil, ErrClientBug // a bare sentence, no code
}
// What the caller does with each of them:
// nil store ServerNonce, seq++, sleep to half of ExpiresAt
// ErrOffline back off, then resend the IDENTICAL body: same seq, same nonce
// ErrSameBeatAgain mint a FRESH DPoP proof, repeat this beat: same seq, same nonce
// ErrRefreshFirst refresh (seq back to 1, chain carries over); activate only
// if the refresh is refused as well
// ErrReactivateOnce back off, activate ONCE; if the next beat refuses again, stop
// ErrStaleRevocation stop the licensed work, activate, then honour the answer
// ErrTerminal stop. Do not loop.
// ErrClientBug the Authorization header is missing or blank. Fix the
// header; another activation will not help
// ErrPermanent stop. Retrying this on a timer loops forever
#[derive(Deserialize)]
struct Beat {
#[serde(rename = "expiresAt")] expires_at: Option<String>,
#[serde(rename = "serverNonce")] server_nonce: Option<String>,
#[serde(rename = "clientNonce")] client_nonce: Option<String>,
error: Option<String>,
}
// deviceHash is REQUIRED by the strict body schema. Sending only seq is a 400
// on every beat, and the build header is required as soon as the product asks
// for one.
fn beat(seq: u32, server_nonce: &str) -> Result<Beat> {
let client_nonce: String = (0..16)
.map(|_| char::from_digit(rand::random::<u32>() % 16, 16).unwrap())
.collect();
let res = client
.post(format!("{base}/v1/sess/heartbeat"))
.bearer_auth(&access_token)
.header("x-licentry-build", &build_token)
.header("x-licentry-protocol", "1")
.json(&json!({
"seq": seq,
"deviceHash": device_hash,
"serverNonce": server_nonce,
"clientNonce": client_nonce,
}))
.send()?;
let status = res.status().as_u16();
let headers = res.headers().clone();
let raw = res.bytes()?; // the signature covers these exact bytes
// Not a licence decision, and need not come from the app at all: back off
// and resend the IDENTICAL body, same seq and same server_nonce.
if status == 429 || status >= 500 {
bail!(Offline);
}
// Everything below decides whether the product keeps running, so verify
// before reading a field. An unsigned response is refused here.
match verify(status, &headers, &raw, &pinned) {
Ok(()) => {}
Err(Error::Refused(_)) => {} // signed refusal, classified below
Err(e) => bail!(e), // unverified: drop it and stop
}
let b: Beat = serde_json::from_slice(&raw)?;
if b.client_nonce.as_deref() != Some(client_nonce.as_str()) {
bail!(Terminal);
}
match status {
200 => return Ok(b),
403 | 426 => bail!(Terminal), // killed, suspended, build retired
409 => bail!(RefreshFirst), // seq out of step
401 => {}
_ => bail!(Permanent), // a 400 never clears on a timer
}
match b.error.as_deref().unwrap_or("") {
"invalid_session" => bail!(ReactivateOnce),
"expired" => bail!(RefreshFirst),
"revoked" => bail!(Terminal),
"stale_revocation" => bail!(StaleRevocation),
"nonce_mismatch" => bail!(ReactivateOnce), // a refresh cannot repair the chain
e if e.starts_with("dpop_") => bail!(SameBeatAgain),
_ => bail!(ClientBug), // a bare sentence, no code
}
}
// What the caller does with each of them:
// Ok store server_nonce, seq += 1, sleep to half of expires_at
// Offline back off, then resend the IDENTICAL body: same seq, same nonce
// SameBeatAgain mint a FRESH DPoP proof, repeat this beat: same seq, same nonce
// RefreshFirst refresh (seq back to 1, chain carries over); activate only if
// the refresh is refused as well
// ReactivateOnce back off, activate ONCE; if the next beat refuses again, stop
// StaleRevocation stop the licensed work, activate, then honour the answer
// Terminal stop. Do not loop.
// ClientBug the Authorization header is missing or blank. Fix the header;
// another activation will not help
// Permanent stop. Retrying this on a timer loops forever
record Beat(boolean ok, String expiresAt, long revocationVersion,
String serverNonce, String clientNonce, String error) {}
int seq = 1; // 1 after activate and after every refresh
String serverNonce = ""; // empty on the first beat
boolean reactivated = false;
while (running) {
byte[] n = new byte[8];
SECURE_RANDOM.nextBytes(n);
String clientNonce = HexFormat.of().formatHex(n);
var body = MAPPER.writeValueAsString(Map.of(
"seq", seq, "deviceHash", deviceHash,
"serverNonce", serverNonce, "clientNonce", clientNonce));
var req = HttpRequest.newBuilder(URI.create(base + "/v1/sess/heartbeat"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + accessToken)
.header("x-licentry-build", buildToken)
.header("x-licentry-protocol", "1")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
var res = client.send(req, HttpResponse.BodyHandlers.ofByteArray());
int code = res.statusCode();
// Not licence decisions, and they need not come from the app at all. Back
// off and resend the IDENTICAL body: same seq, same serverNonce.
if (code == 429 || code >= 500) { Thread.sleep(30_000); continue; }
// Everything below decides whether the product keeps running, so verify
// before parsing. A response with no X-Licentry-Sig fails here.
if (!verify(res, PINNED)) { shutdown(); break; }
Beat beat = MAPPER.readValue(res.body(), Beat.class);
if (!clientNonce.equals(beat.clientNonce())) { shutdown(); break; }
// Terminal. Do not retry: an error body has no expiresAt.
if (code == 403 || code == 426) { shutdown(); break; }
if (code == 409) { // seq out of step
if (refresh() == null) { shutdown(); break; }
seq = 1; continue; // the nonce chain carries over
}
if (code == 401) {
String err = beat.error() == null ? "" : beat.error();
if (err.equals("invalid_session")) { // evicted or gone: activate ONCE
if (reactivated) { shutdown(); break; }
reactivated = true;
Thread.sleep(5_000); // back off before taking the seat back
activate(); seq = 1; serverNonce = ""; continue;
}
if (err.equals("expired")) { // refresh, activate only if that fails too
if (refresh() == null) { activate(); serverNonce = ""; }
seq = 1; continue;
}
if (err.equals("revoked")) { shutdown(); break; } // terminal. Do not loop.
if (err.equals("stale_revocation")) { // stop the work, then honour the answer
stopLicensedWork();
activate(); seq = 1; serverNonce = ""; continue;
}
if (err.equals("nonce_mismatch")) { // only a new session repairs the chain
activate(); seq = 1; serverNonce = ""; continue;
}
// dpop_required and every other dpop_ code: mint a FRESH proof and
// repeat this beat unchanged, same seq and same serverNonce.
if (err.startsWith("dpop_")) { continue; }
// A bare sentence with no code: the Authorization header is missing or
// blank. That is a bug here, and activating again will not fix it.
shutdown(); break;
}
if (code != 200) { shutdown(); break; } // 400 and friends never clear on a timer
serverNonce = beat.serverNonce() == null ? "" : beat.serverNonce();
seq++;
Thread.sleep(halfOf(beat.expiresAt()));
}
$seq = 1; // 1 after activate and after every refresh
$serverNonce = ''; // empty on the first beat
$reactivated = false;
while ($running) {
$clientNonce = bin2hex(random_bytes(8));
$ch = curl_init("$base/v1/sess/heartbeat");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"Authorization: Bearer $accessToken",
"x-licentry-build: $buildToken",
"x-licentry-protocol: 1",
],
CURLOPT_POSTFIELDS => json_encode([
'seq' => $seq, 'deviceHash' => $deviceHash,
'serverNonce' => $serverNonce, 'clientNonce' => $clientNonce,
]),
]);
$h = [];
collectHeaders($ch, $h);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
// Not licence decisions, and they need not come from the app at all. Back
// off and resend the IDENTICAL body: same $seq, same $serverNonce.
if ($code === 429 || $code >= 500) { sleep(30); continue; }
// Everything below decides whether the product keeps running, so verify
// before reading a field. An unsigned answer is refused here.
if (!licentryVerify($code, $h, $raw, $pinned)) { shutdown(); break; }
$beat = json_decode($raw, true);
if (($beat['clientNonce'] ?? null) !== $clientNonce) { shutdown(); break; }
$err = $beat['error'] ?? '';
// Terminal: stop the licensed work. Anything that continues here would
// dereference expiresAt on an error body and loop forever.
if ($code === 403 || $code === 426) { shutdown(); break; }
if ($code === 409) { // seq out of step
if (refreshSession() === null) { shutdown(); break; }
$seq = 1; continue; // the nonce chain carries over
}
if ($code === 401) {
if ($err === 'invalid_session') { // evicted or gone: activate ONCE
if ($reactivated) { shutdown(); break; }
$reactivated = true;
sleep(5); // back off before taking the seat back
activate(); $seq = 1; $serverNonce = ''; continue;
}
if ($err === 'expired') { // refresh, activate only if that fails too
if (refreshSession() === null) { activate(); $serverNonce = ''; }
$seq = 1; continue;
}
if ($err === 'revoked') { shutdown(); break; } // terminal. Do not loop.
if ($err === 'stale_revocation') { // stop the work, then honour the answer
stopLicensedWork();
activate(); $seq = 1; $serverNonce = ''; continue;
}
if ($err === 'nonce_mismatch') { // only a new session repairs the chain
activate(); $seq = 1; $serverNonce = ''; continue;
}
// dpop_required and every other dpop_ code: mint a FRESH proof and
// repeat this beat unchanged, same $seq and same $serverNonce.
if (str_starts_with($err, 'dpop_')) { continue; }
// A bare sentence with no code: the Authorization header is missing or
// blank. That is a bug here, and activating again will not fix it.
shutdown(); break;
}
if ($code !== 200) { shutdown(); break; } // a 400 never clears on a timer
$serverNonce = $beat['serverNonce'] ?? '';
$seq++;
sleep(halfOf($beat['expiresAt']));
}
struct Beat: Decodable {
let expiresAt: String?
let serverNonce: String?
let clientNonce: String?
let error: String?
}
var seq = 1 // 1 after activate and after every refresh
var serverNonce = "" // empty on the first beat
func beat() async throws -> Beat {
var bytes = [UInt8](repeating: 0, count: 8)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
let clientNonce = bytes.map { String(format: "%02x", $0) }.joined()
var req = URLRequest(url: base.appendingPathComponent("v1/sess/heartbeat"))
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
req.setValue(buildToken, forHTTPHeaderField: "x-licentry-build")
req.setValue("1", forHTTPHeaderField: "x-licentry-protocol")
req.httpBody = try JSONEncoder().encode([
"seq": AnyEncodable(seq), "deviceHash": AnyEncodable(deviceHash),
"serverNonce": AnyEncodable(serverNonce),
"clientNonce": AnyEncodable(clientNonce),
])
let (data, resp) = try await URLSession.shared.data(for: req)
let http = resp as! HTTPURLResponse
let code = http.statusCode
// Not a licence decision, and need not come from the app at all: back off
// and resend the IDENTICAL body, same seq and same serverNonce.
if code == 429 || code >= 500 { throw LicenceError.offline }
// Everything below decides whether the product keeps running, so verify
// before reading a field. An unsigned response is refused in there.
guard verify(http, body: data, pinned: pinned) else { throw LicenceError.terminal }
let b = try JSONDecoder().decode(Beat.self, from: data)
guard b.clientNonce == clientNonce else { throw LicenceError.terminal }
switch code {
case 200: break
case 403, 426: throw LicenceError.terminal // killed, suspended, retired
case 409: throw LicenceError.refreshFirst // seq out of step
case 401:
switch b.error ?? "" {
case "invalid_session": throw LicenceError.reactivateOnce
case "expired": throw LicenceError.refreshFirst
case "revoked": throw LicenceError.terminal
case "stale_revocation": throw LicenceError.staleRevocation
case "nonce_mismatch": throw LicenceError.reactivateOnce
case let e where e.hasPrefix("dpop_"): throw LicenceError.sameBeatAgain
default: throw LicenceError.clientBug // a bare sentence, no code
}
default: throw LicenceError.permanent // a 400 never clears on a timer
}
serverNonce = b.serverNonce ?? ""
seq += 1
return b
}
// What the caller does with each of them:
// returned Beat store serverNonce, seq += 1, sleep to half of expiresAt
// offline back off, then resend the IDENTICAL body: same seq, same nonce
// sameBeatAgain mint a FRESH DPoP proof, repeat this beat: same seq, same nonce
// refreshFirst refresh (seq back to 1, chain carries over); activate only if
// the refresh is refused as well
// reactivateOnce back off, activate ONCE; if the next beat refuses again, stop
// staleRevocation stop the licensed work, activate, then honour the answer
// terminal stop. Do not loop.
// clientBug the Authorization header is missing or blank. Fix the header;
// another activation will not help
// permanent stop. Retrying this on a timer loops forever
data class Beat(val expiresAt: String, val serverNonce: String?,
val clientNonce: String?, val error: String?)
var seq = 1 // 1 after activate and after every refresh
var serverNonce = "" // empty on the first beat
var reactivated = false
while (running) {
val clientNonce = ByteArray(8).also { SecureRandom().nextBytes(it) }
.joinToString("") { "%02x".format(it) }
val body = gson.toJson(mapOf(
"seq" to seq, "deviceHash" to deviceHash,
"serverNonce" to serverNonce, "clientNonce" to clientNonce))
val req = Request.Builder()
.url("$base/v1/sess/heartbeat")
.addHeader("Authorization", "Bearer $accessToken")
.addHeader("x-licentry-build", buildToken)
.addHeader("x-licentry-protocol", "1")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
client.newCall(req).execute().use { res ->
val code = res.code
// Not licence decisions, and they need not come from the app at all.
// Back off and resend the IDENTICAL body: same seq, same serverNonce.
if (code == 429 || code >= 500) { Thread.sleep(30_000); return@use }
val raw = res.body!!.bytes()
// Everything below decides whether the product keeps running, so verify
// before parsing. An unsigned response is refused inside verify.
if (!verify(res, raw, pinned)) { shutdown(); return }
val beat = gson.fromJson(String(raw), Beat::class.java)
if (beat.clientNonce != clientNonce) { shutdown(); return }
val err = beat.error ?: ""
when {
// Terminal. Parsing an error body for expiresAt throws instead of
// stopping, which reads as a crash rather than a licence decision.
code == 403 || code == 426 -> { shutdown(); return }
code == 409 -> { // seq out of step
if (refresh() == null) { shutdown(); return }
seq = 1 // the nonce chain carries over
}
code == 401 && err == "invalid_session" -> { // evicted or gone
if (reactivated) { shutdown(); return } // activate ONCE
reactivated = true
Thread.sleep(5_000) // back off before taking the seat back
activate(); seq = 1; serverNonce = ""
}
code == 401 && err == "expired" -> { // refresh, activate only if that fails
if (refresh() == null) { activate(); serverNonce = "" }
seq = 1
}
code == 401 && err == "revoked" -> { shutdown(); return } // do not loop
code == 401 && err == "stale_revocation" -> {
stopLicensedWork() // stop first, then honour the answer
activate(); seq = 1; serverNonce = ""
}
code == 401 && err == "nonce_mismatch" -> { // only a new session repairs it
activate(); seq = 1; serverNonce = ""
}
// dpop_required and every other dpop_ code: mint a FRESH proof and
// repeat this beat unchanged, same seq and same serverNonce.
code == 401 && err.startsWith("dpop_") -> return@use
// A bare sentence with no code: the Authorization header is missing
// or blank. A bug here, and activating again will not fix it.
code == 401 -> { shutdown(); return }
code == 200 -> {
serverNonce = beat.serverNonce ?: ""
seq++
Thread.sleep(halfOf(beat.expiresAt))
}
else -> { shutdown(); return } // a 400 never clears on a timer
}
}
}
seq = 1 # 1 after activate and after every refresh
server_nonce = '' # empty on the first beat
reactivated = false
while running
client_nonce = SecureRandom.hex(8)
res = Net::HTTP.post(
URI("#{BASE}/v1/sess/heartbeat"),
{ seq: seq, deviceHash: device_hash,
serverNonce: server_nonce, clientNonce: client_nonce }.to_json,
'Content-Type' => 'application/json',
'Authorization' => "Bearer #{access_token}",
'x-licentry-build' => BUILD_TOKEN,
'x-licentry-protocol' => '1'
)
# Not licence decisions, and they need not come from the app at all. Back off
# and resend the IDENTICAL body: same seq, same server_nonce.
code = res.code.to_i
if code == 429 || code >= 500
sleep 30
next
end
# Everything below decides whether the product keeps running, so verify before
# reading a field. verify! raises on an unsigned or forged response.
status, beat = verify!(res, res.body, PINNED)
(shutdown; break) unless beat['clientNonce'] == client_nonce
err = beat['error'].to_s
# Terminal. Without these branches, sleep(nil) on an error body is the whole
# failure mode: a revoked licence keeps the process alive and spinning.
case status
when 403, 426 then shutdown; break
when 409 # seq out of step
(shutdown; break) unless refresh
seq = 1 # the nonce chain carries over
next
when 401
case err
when 'invalid_session' # evicted or gone: activate ONCE
(shutdown; break) if reactivated
reactivated = true
sleep 5 # back off before taking the seat back
activate; seq = 1; server_nonce = ''; next
when 'expired' # refresh, activate only if that fails too
unless refresh
activate
server_nonce = ''
end
seq = 1
next
when 'revoked' then shutdown; break # terminal. Do not loop.
when 'stale_revocation' # stop the work, then honour the answer
stop_licensed_work
activate; seq = 1; server_nonce = ''; next
when 'nonce_mismatch' # only a new session repairs the chain
activate; seq = 1; server_nonce = ''; next
else
# dpop_required and every other dpop_ code: mint a FRESH proof and repeat
# this beat unchanged, same seq and same server_nonce.
next if err.start_with?('dpop_')
# A bare sentence with no code: the Authorization header is missing or
# blank. That is a bug here, and activating again will not fix it.
shutdown
break
end
when 200 then nil
else shutdown; break # a 400 never clears on a timer
end
server_nonce = beat['serverNonce'].to_s
seq += 1
sleep half_of(beat['expiresAt'])
end
4 · Refresh before expiry
Rotate tokens before refreshExpiresAt. After a refresh the sequence resets, so your next heartbeat is seq: 1 again. The nonce chain does not reset with it: carry the last serverNonce you were issued across the refresh and echo it on that beat.
CN=$(openssl rand -hex 8)
CODE=$(curl -sS -D ref.hdr -o ref.json -w '%{http_code}' \
-X POST "$BASE/v1/sess/refresh" \
-H "Content-Type: application/json" \
-H "x-licentry-build: $BUILD_TOKEN" \
-H "x-licentry-protocol: 1" \
-d "$(printf '{"refreshToken":"%s","deviceHash":"%s","clientNonce":"%s"}' \
"$REFRESH_TOKEN" "$DEVICE_HASH" "$CN")")
# Signature, freshness, echo and status, all before a token is read out of the
# body. An unsigned answer is refused here, not waved through.
# 401 refresh_token_stale or invalid_session: the pair is dead, activate again
# 403 license_suspended, 426 build retired: terminal, stop
licentry_accepted "$CODE" ref.hdr ref.json "$CN" || exit 1
ACCESS_TOKEN=$(jq -r .accessToken ref.json)
REFRESH_TOKEN=$(jq -r .refreshToken ref.json)
EXPIRES_AT=$(jq -r .expiresAt ref.json)
SEQ=1 # the heartbeat sequence starts over at 1
# The nonce chain does NOT reset: keep the last SERVER_NONCE you hold. Refresh is
# also the only thing that refills the nonce retry budget, which is three.
// Returns false when the refresh was refused. Never returns a half-filled
// session: the caller must not keep using the old tokens after a refusal.
bool refreshSession(CURL* c, const Cfg& cfg, const std::string& base,
const std::string& refreshToken,
const std::string& deviceHash, Session& out) {
// deviceHash is required: the deployment sets LICENSE_REFRESH_REQUIRE_DEVICE
// and a bound session demands it regardless.
std::string cn = randomHex(8);
std::string body = R"({"refreshToken":")" + refreshToken +
R"(","deviceHash":")" + deviceHash +
R"(","clientNonce":")" + cn + R"("})";
std::string resp;
SigHdr hdr;
curl_slist* h = curl_slist_append(nullptr, "Content-Type: application/json");
h = curl_slist_append(h, ("x-licentry-build: " + cfg.buildToken).c_str());
h = curl_slist_append(h, "x-licentry-protocol: 1");
curl_easy_setopt(c, CURLOPT_URL, (base + "/v1/sess/refresh").c_str());
curl_easy_setopt(c, CURLOPT_POSTFIELDS, body.c_str());
curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, sink);
curl_easy_setopt(c, CURLOPT_WRITEDATA, &resp);
curl_easy_setopt(c, CURLOPT_HEADERFUNCTION, hdrSink);
curl_easy_setopt(c, CURLOPT_HEADERDATA, &hdr);
curl_easy_perform(c);
long code = 0;
curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &code);
curl_slist_free_all(h);
// Signature, freshness and status in one call, and an unsigned answer is
// refused here: 401 means activate again, 403 and 426 are terminal.
if (!accepted(cfg.pinned(hdr.kid), (int)code, hdr.ts, resp, hdr.sig, time(nullptr)))
return false;
auto j = json::parse(resp, nullptr, false);
if (j.is_discarded() || j.value("clientNonce", "") != cn) return false;
out.accessToken = j["accessToken"];
out.refreshToken = j["refreshToken"];
out.expiresAt = j["expiresAt"];
out.seq = 1; // the heartbeat sequence restarts; out.serverNonce does not
return true;
}
record Refreshed(string accessToken, string refreshToken,
string expiresAt, string clientNonce);
// Returns null when the refresh was refused. The caller must stop, not carry on
// with the tokens it already had.
async Task<Refreshed?> Refresh() {
var clientNonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(8));
var req = new HttpRequestMessage(HttpMethod.Post, "/v1/sess/refresh") {
Content = JsonContent.Create(new { refreshToken, deviceHash, clientNonce })
};
req.Headers.Add("x-licentry-build", buildToken);
req.Headers.Add("x-licentry-protocol", "1");
var res = await http.SendAsync(req);
var raw = await res.Content.ReadAsByteArrayAsync();
// A refusal is not a session. Filling a Session object from an error body
// leaves the old access token in place and reads as success to the caller.
// Verify first, so an unsigned answer is refused rather than waved through.
Verified v;
try { v = Verify(res, raw, pinned); }
catch (CryptographicException) { return null; }
if (v.Status != 200) return null; // 401 activate again, 403 and 426 terminal
var r = JsonSerializer.Deserialize<Refreshed>(v.Body);
if (r is null || r.clientNonce != clientNonce) return null;
return r; // seq restarts at 1; the nonce chain carries straight across
}
def refresh():
client_nonce = secrets.token_hex(8)
r = requests.post(
f"{BASE}/v1/sess/refresh",
headers={"x-licentry-build": BUILD_TOKEN, "x-licentry-protocol": "1"},
json={"refreshToken": refresh_token, "deviceHash": device_hash,
"clientNonce": client_nonce},
timeout=10,
)
# Verify first, and refuse an unsigned answer instead of waving it through.
# Indexing the body straight away raises KeyError on a refusal, which is a
# crash where a decision belongs.
try:
status, s = verify(r, PINNED)
except InvalidSignature:
return None
if status != 200:
# 401 stale or invalid: activate again. 403/426: terminal.
return None
if s.get("clientNonce") != client_nonce:
return None
return {
"access_token": s["accessToken"],
"refresh_token": s["refreshToken"],
"expires_at": s["expiresAt"],
"seq": 1, # heartbeat sequence restarts; the nonce chain does not
}
async function refresh(session) {
const clientNonce = randomBytes(8).toString('hex');
const res = await fetch(`${BASE}/v1/sess/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-licentry-build': BUILD_TOKEN,
'x-licentry-protocol': '1',
},
body: JSON.stringify({
refreshToken: session.refreshToken, deviceHash, clientNonce,
}),
});
// Do NOT merge the body into the session. Object.assign on a refusal keeps
// the old accessToken, writes an error field onto the session and returns it
// as though the refresh had worked, so a suspended licence carries on.
// Verify first, so an unsigned answer is refused rather than waved through.
let status, s;
try {
({ status, body: s } = await verifySigned(res, pinned));
} catch {
return null;
}
if (status !== 200) return null; // 401 activate again, 403 and 426 terminal
if (s.clientNonce !== clientNonce) return null;
return {
accessToken: s.accessToken,
refreshToken: s.refreshToken,
expiresAt: s.expiresAt,
seq: 1, // heartbeat sequence restarts; the nonce chain does not
};
}
// refresh returns (nil, err) on any refusal. It never returns a zero-valued
// Session with a nil error, which the caller would read as success.
func refresh() (*Session, error) {
cn := make([]byte, 8)
rand.Read(cn)
clientNonce := hex.EncodeToString(cn)
body, _ := json.Marshal(map[string]any{
"refreshToken": refreshToken, "deviceHash": deviceHash,
"clientNonce": clientNonce,
})
req, _ := http.NewRequest("POST", base+"/v1/sess/refresh", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-licentry-build", buildToken)
req.Header.Set("x-licentry-protocol", "1")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body) // the signature covers these exact bytes
// Verify before the status, so an unsigned answer is refused rather than
// waved through, and gate on Verify plus the status rather than on Verify
// alone: the server signs its refusals too.
if !Verify(res, raw, pinned) {
return nil, ErrTerminal
}
switch res.StatusCode {
case 200:
case 401:
return nil, ErrReactivateOnce
case 403, 426:
return nil, ErrTerminal
default:
return nil, ErrOffline
}
var s struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresAt string `json:"expiresAt"`
ClientNonce string `json:"clientNonce"`
}
if err := json.Unmarshal(raw, &s); err != nil {
return nil, err
}
if s.ClientNonce != clientNonce {
return nil, ErrTerminal
}
return &Session{AccessToken: s.AccessToken, RefreshToken: s.RefreshToken,
ExpiresAt: s.ExpiresAt, Seq: 1}, nil
}
#[derive(Deserialize)]
struct Refreshed {
#[serde(rename = "accessToken")] access_token: String,
#[serde(rename = "refreshToken")] refresh_token: String,
#[serde(rename = "expiresAt")] expires_at: String,
#[serde(rename = "clientNonce")] client_nonce: Option<String>,
}
fn refresh() -> Result<Session> {
let client_nonce: String = (0..16)
.map(|_| char::from_digit(rand::random::<u32>() % 16, 16).unwrap())
.collect();
let res = client
.post(format!("{base}/v1/sess/refresh"))
.header("x-licentry-build", &build_token)
.header("x-licentry-protocol", "1")
.json(&json!({
"refreshToken": refresh_token,
"deviceHash": device_hash,
"clientNonce": client_nonce,
}))
.send()?;
let status = res.status().as_u16();
let headers = res.headers().clone();
let raw = res.bytes()?;
// Verify before the status, so an unsigned answer is refused rather than
// waved through. Err(Refused) means the server signed a no, and it is a no.
match verify(status, &headers, &raw, &pinned) {
Ok(()) => {}
Err(Error::Refused(401)) => bail!(ReactivateOnce), // stale or invalid pair
Err(Error::Refused(403 | 426)) => bail!(Terminal),
Err(Error::Refused(_)) => bail!(Offline),
Err(_) => bail!(Unverified), // unsigned or forged
}
let s: Refreshed = serde_json::from_slice(&raw)?;
if s.client_nonce.as_deref() != Some(client_nonce.as_str()) {
bail!(Terminal);
}
Ok(Session { access_token: s.access_token, refresh_token: s.refresh_token,
expires_at: s.expires_at, seq: 1 })
}
record Refreshed(String accessToken, String refreshToken,
String expiresAt, String clientNonce) {}
// Returns null on any refusal. Do not rely on the JSON mapper to fail: with
// unknown-property handling relaxed, an error body deserializes into a record
// full of nulls and the caller carries on with a session that has no tokens.
Refreshed refresh() throws Exception {
byte[] n = new byte[8];
SECURE_RANDOM.nextBytes(n);
String clientNonce = HexFormat.of().formatHex(n);
var body = MAPPER.writeValueAsString(Map.of(
"refreshToken", refreshToken, "deviceHash", deviceHash,
"clientNonce", clientNonce));
var req = HttpRequest.newBuilder(URI.create(base + "/v1/sess/refresh"))
.header("Content-Type", "application/json")
.header("x-licentry-build", buildToken)
.header("x-licentry-protocol", "1")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
var res = client.send(req, HttpResponse.BodyHandlers.ofByteArray());
// Gate on accepted rather than on the status alone: an unsigned answer is
// refused here, and the server signs its refusals as well as its approvals.
// 401 means activate again, 403 and 426 are terminal.
if (!accepted(res, PINNED)) return null;
Refreshed r = MAPPER.readValue(res.body(), Refreshed.class);
if (r.accessToken() == null || !clientNonce.equals(r.clientNonce())) return null;
return r; // seq restarts at 1; the nonce chain carries straight across
}
function refreshSession(string $base, string $refreshToken, string $deviceHash,
string $buildToken, array $pinned): ?array {
$clientNonce = bin2hex(random_bytes(8));
$ch = curl_init("$base/v1/sess/refresh");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"x-licentry-build: $buildToken",
"x-licentry-protocol: 1",
],
CURLOPT_POSTFIELDS => json_encode([
'refreshToken' => $refreshToken,
'deviceHash' => $deviceHash,
'clientNonce' => $clientNonce,
]),
]);
$h = [];
collectHeaders($ch, $h);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
// null, not an array with null tokens. A caller that gets back a shape it
// recognises will use it. Gate on licentryAccepted so an unsigned answer is
// refused too: 401 means activate again, 403 and 426 are terminal.
if (!licentryAccepted($code, $h, $raw, $pinned)) return null;
$s = json_decode($raw, true);
if (($s['clientNonce'] ?? null) !== $clientNonce) return null;
return [
'accessToken' => $s['accessToken'],
'refreshToken' => $s['refreshToken'],
'expiresAt' => $s['expiresAt'],
'seq' => 1,
];
}
struct Refreshed: Decodable {
let accessToken: String
let refreshToken: String
let expiresAt: String
let clientNonce: String?
}
func refresh() async throws -> Refreshed {
var bytes = [UInt8](repeating: 0, count: 8)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
let clientNonce = bytes.map { String(format: "%02x", $0) }.joined()
var req = URLRequest(url: base.appendingPathComponent("v1/sess/refresh"))
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue(buildToken, forHTTPHeaderField: "x-licentry-build")
req.setValue("1", forHTTPHeaderField: "x-licentry-protocol")
req.httpBody = try JSONEncoder().encode([
"refreshToken": refreshToken, "deviceHash": deviceHash,
"clientNonce": clientNonce,
])
let (data, resp) = try await URLSession.shared.data(for: req)
let http = resp as! HTTPURLResponse
// Verify before the status, so an unsigned answer is refused rather than
// waved through, and read the status only once the signature checks out.
guard verify(http, body: data, pinned: pinned) else { throw LicenceError.terminal }
switch http.statusCode {
case 200: break
case 401: throw LicenceError.reactivateOnce
case 403, 426: throw LicenceError.terminal
default: throw LicenceError.offline
}
let s = try JSONDecoder().decode(Refreshed.self, from: data)
guard s.clientNonce == clientNonce else { throw LicenceError.terminal }
return s
}
data class Refreshed(val accessToken: String?, val refreshToken: String?,
val expiresAt: String?, val clientNonce: String?)
// Returns null on a refusal. Gson fills every field with null from an error
// body without complaining, so the status check is the only real guard.
fun refresh(): Refreshed? {
val clientNonce = ByteArray(8).also { SecureRandom().nextBytes(it) }
.joinToString("") { "%02x".format(it) }
val body = gson.toJson(mapOf(
"refreshToken" to refreshToken, "deviceHash" to deviceHash,
"clientNonce" to clientNonce))
val req = Request.Builder()
.url("$base/v1/sess/refresh")
.addHeader("x-licentry-build", buildToken)
.addHeader("x-licentry-protocol", "1")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
client.newCall(req).execute().use { res ->
val raw = res.body!!.bytes()
// Gate on accepted rather than on the status alone: an unsigned answer
// is refused here too. 401 means activate again, 403 and 426 terminal.
if (!accepted(res, raw, pinned)) return null
val s = gson.fromJson(String(raw), Refreshed::class.java)
if (s.accessToken == null || s.clientNonce != clientNonce) return null
return s // seq restarts at 1; the nonce chain carries straight across
}
}
def refresh
client_nonce = SecureRandom.hex(8)
res = Net::HTTP.post(
URI("#{BASE}/v1/sess/refresh"),
{ refreshToken: refresh_token, deviceHash: device_hash,
clientNonce: client_nonce }.to_json,
'Content-Type' => 'application/json',
'x-licentry-build' => BUILD_TOKEN,
'x-licentry-protocol' => '1'
)
# nil, not a Session with nil tokens. Returning a shape the caller recognises
# is how a refusal turns into a session that quietly does nothing. Verify
# first, so an unsigned answer is refused rather than waved through.
begin
status, s = verify!(res, res.body, PINNED)
rescue StandardError
return nil
end
return nil unless status == 200 # 401 activate again, 403 and 426 are terminal
return nil unless s['clientNonce'] == client_nonce
Session.new(
access_token: s['accessToken'],
refresh_token: s['refreshToken'],
expires_at: s['expiresAt'],
seq: 1 # heartbeat sequence restarts; the nonce chain does not
)
end
5 · Logout on sign-out and on clean shutdown
A session that is never logged out holds its concurrency seat until the refresh window closes, which is days. On the default cap of one, your customer's own next launch evicts their own abandoned session, and that eviction is counted as sharing signal against them. If the session is DPoP-bound, send a proof over the logout URL or the call is refused and the seat stays held.
# Call this when the user quits. maxConcurrentSessions defaults to 1, and a
# session that is never logged out holds its seat until the refresh window
# closes, which is days: the customer's own next launch is what gets evicted.
CN=$(openssl rand -hex 8)
CODE=$(curl -sS -D out.hdr -o out.json -w '%{http_code}' \
-X POST "$BASE/v1/sess/logout" \
-H "Content-Type: application/json" \
-H "x-licentry-build: $BUILD_TOKEN" \
-H "x-licentry-protocol: 1" \
-d "$(jq -cn --arg t "$ACCESS_TOKEN" --arg n "$CN" \
'{accessToken: $t, clientNonce: $n}')")
# On a DPoP bound session, add a DPoP header over this URL with ath over the
# access token. Without one a strict deployment answers 401 dpop_required, the
# logout does not happen, and the seat stays held.
# 200 {"ok":true}, never 204: a signature over an empty body proves nothing, so
# this route answers with a body, and the body carries the echo.
licentry_accepted "$CODE" out.hdr out.json "$CN" || exit 1
// Call this when the user quits. maxConcurrentSessions defaults to 1, and a
// session that is never logged out holds its seat until the refresh window
// closes, which is days: the customer's own next launch is what gets evicted.
bool logout(CURL* c, const Cfg& cfg, const std::string& base,
const std::string& accessToken) {
const std::string cn = randomHex(8);
std::string body = R"({"accessToken":")" + accessToken +
R"(","clientNonce":")" + cn + R"("})";
std::string resp;
SigHdr hdr;
curl_slist* h = curl_slist_append(nullptr, "Content-Type: application/json");
h = curl_slist_append(h, ("x-licentry-build: " + cfg.buildToken).c_str());
h = curl_slist_append(h, "x-licentry-protocol: 1");
// On a DPoP bound session, add a DPoP header over this URL with ath over the
// access token. Without one a strict deployment answers 401 dpop_required, the
// logout does not happen, and the seat stays held.
curl_easy_setopt(c, CURLOPT_URL, (base + "/v1/sess/logout").c_str());
curl_easy_setopt(c, CURLOPT_POSTFIELDS, body.c_str());
curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, sink);
curl_easy_setopt(c, CURLOPT_WRITEDATA, &resp);
curl_easy_setopt(c, CURLOPT_HEADERFUNCTION, hdrSink);
curl_easy_setopt(c, CURLOPT_HEADERDATA, &hdr);
curl_easy_perform(c);
long code = 0;
curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &code);
curl_slist_free_all(h);
// 200 {"ok":true}, never 204: a signature over an empty body proves nothing,
// so this route answers with a body, and the body carries the echo.
if (!accepted(cfg.pinned(hdr.kid), (int)code, hdr.ts, resp, hdr.sig, time(nullptr)))
return false;
auto j = json::parse(resp, nullptr, false);
return !j.is_discarded() && j.value("clientNonce", "") == cn;
}
// Call this when the user quits. maxConcurrentSessions defaults to 1, and a
// session that is never logged out holds its seat until the refresh window
// closes, which is days: the customer's own next launch is what gets evicted.
async Task<bool> Logout(HttpClient http, string accessToken, string buildToken,
IReadOnlyDictionary<string, ECDsa> pinned) {
var clientNonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(8));
// On a DPoP bound session, add a DPoP header over this URL with ath over the
// access token. Without one a strict deployment answers 401 dpop_required,
// the logout does not happen, and the seat stays held.
var req = new HttpRequestMessage(HttpMethod.Post, "/v1/sess/logout") {
Content = JsonContent.Create(new { accessToken, clientNonce })
};
req.Headers.Add("x-licentry-build", buildToken);
req.Headers.Add("x-licentry-protocol", "1");
var res = await http.SendAsync(req);
var raw = await res.Content.ReadAsByteArrayAsync();
// 200 {"ok":true}, never 204: a signature over an empty body proves nothing,
// so this route answers with a body, and the body carries the echo.
Verified v;
try { v = Verify(res, raw, pinned); }
catch (CryptographicException) { return false; }
if (v.Status != 200) return false;
var body = JsonSerializer.Deserialize<JsonElement>(v.Body);
return body.TryGetProperty("clientNonce", out var echo) &&
echo.GetString() == clientNonce;
}
# Call this when the user quits. maxConcurrentSessions defaults to 1, and a
# session that is never logged out holds its seat until the refresh window
# closes, which is days: the customer's own next launch is what gets evicted.
#
# On a DPoP bound session, add a DPoP header over this URL with ath over the
# access token. Without one a strict deployment answers 401 dpop_required, the
# logout does not happen, and the seat stays held.
def logout(base, build_token, access_token, pinned):
client_nonce = secrets.token_hex(8)
r = requests.post(
f"{base}/v1/sess/logout",
headers={"Content-Type": "application/json",
"x-licentry-build": build_token, "x-licentry-protocol": "1"},
json={"accessToken": access_token, "clientNonce": client_nonce},
timeout=10,
)
# 200 {"ok":true}, never 204: a signature over an empty body proves nothing,
# so this route answers with a body, and the body carries the echo.
try:
status, body = verify(r, pinned)
except InvalidSignature:
return False
return status == 200 and body.get("clientNonce") == client_nonce
// Call this when the user quits. maxConcurrentSessions defaults to 1, and a
// session that is never logged out holds its seat until the refresh window
// closes, which is days: the customer's own next launch is what gets evicted.
//
// On a DPoP bound session, add a DPoP header over this URL with ath over the
// access token. Without one a strict deployment answers 401 dpop_required, the
// logout does not happen, and the seat stays held.
export async function logout({ base, buildToken, accessToken, pinned }) {
const clientNonce = randomBytes(8).toString("hex");
const res = await fetch(`${base}/v1/sess/logout`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-licentry-build": buildToken,
"x-licentry-protocol": "1",
},
body: JSON.stringify({ accessToken, clientNonce }),
});
// 200 {"ok":true}, never 204: a signature over an empty body proves nothing,
// so this route answers with a body, and the body carries the echo.
const { status, body } = await verifySigned(res, pinned);
return status === 200 && body.clientNonce === clientNonce;
}
// Logout when the user quits. maxConcurrentSessions defaults to 1, and a
// session that is never logged out holds its seat until the refresh window
// closes, which is days: the customer's own next launch is what gets evicted.
func Logout(base, build, accessToken string, pinned map[string]*ecdsa.PublicKey) bool {
cn := make([]byte, 8)
rand.Read(cn)
clientNonce := hex.EncodeToString(cn)
body, _ := json.Marshal(map[string]string{
"accessToken": accessToken, "clientNonce": clientNonce,
})
// On a DPoP bound session, add a DPoP header over this URL with ath over the
// access token. Without one a strict deployment answers 401 dpop_required,
// the logout does not happen, and the seat stays held.
req, _ := http.NewRequest("POST", base+"/v1/sess/logout", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-licentry-build", build)
req.Header.Set("x-licentry-protocol", "1")
res, err := http.DefaultClient.Do(req)
if err != nil {
return false
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
// 200 {"ok":true}, never 204: a signature over an empty body proves nothing,
// so this route answers with a body, and the body carries the echo.
if !Accepted(res, raw, pinned) {
return false
}
var b struct {
ClientNonce string `json:"clientNonce"`
}
return json.Unmarshal(raw, &b) == nil && b.ClientNonce == clientNonce
}
// Call this when the user quits. maxConcurrentSessions defaults to 1, and a
// session that is never logged out holds its seat until the refresh window
// closes, which is days: the customer's own next launch is what gets evicted.
pub fn logout(base: &str, build: &str, access_token: &str,
pinned: &HashMap<String, VerifyingKey>) -> Result<()> {
let client_nonce: String = (0..16)
.map(|_| char::from_digit(rand::random::<u32>() % 16, 16).unwrap())
.collect();
let res = client
// On a DPoP bound session, add a DPoP header over this URL with ath over
// the access token. Without one a strict deployment answers
// 401 dpop_required, the logout does not happen, and the seat stays held.
.post(format!("{base}/v1/sess/logout"))
.header("x-licentry-build", build)
.header("x-licentry-protocol", "1")
.json(&json!({ "accessToken": access_token, "clientNonce": client_nonce }))
.send()?;
let status = res.status().as_u16();
let headers = res.headers().clone();
let raw = res.bytes()?;
// 200 {"ok":true}, never 204: a signature over an empty body proves nothing,
// so this route answers with a body, and the body carries the echo.
verify(status, &headers, &raw, pinned)?;
let b: Value = serde_json::from_slice(&raw)?;
if b.get("clientNonce").and_then(Value::as_str) != Some(client_nonce.as_str()) {
bail!(Terminal);
}
Ok(())
}
// Call this when the user quits. maxConcurrentSessions defaults to 1, and a
// session that is never logged out holds its seat until the refresh window
// closes, which is days: the customer's own next launch is what gets evicted.
boolean logout(String base, String accessToken, String buildToken) throws Exception {
byte[] n = new byte[8];
SECURE_RANDOM.nextBytes(n);
String clientNonce = HexFormat.of().formatHex(n);
var body = MAPPER.writeValueAsString(
Map.of("accessToken", accessToken, "clientNonce", clientNonce));
// On a DPoP bound session, add a DPoP header over this URL with ath over the
// access token. Without one a strict deployment answers 401 dpop_required,
// the logout does not happen, and the seat stays held.
var req = HttpRequest.newBuilder(URI.create(base + "/v1/sess/logout"))
.header("Content-Type", "application/json")
.header("x-licentry-build", buildToken)
.header("x-licentry-protocol", "1")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
var res = client.send(req, HttpResponse.BodyHandlers.ofByteArray());
// 200 {"ok":true}, never 204: a signature over an empty body proves nothing,
// so this route answers with a body, and the body carries the echo.
if (!accepted(res, PINNED)) return false;
JsonNode echo = MAPPER.readTree(res.body()).get("clientNonce");
return echo != null && clientNonce.equals(echo.asText());
}
<?php
// Call this when the user quits. maxConcurrentSessions defaults to 1, and a
// session that is never logged out holds its seat until the refresh window
// closes, which is days: the customer's own next launch is what gets evicted.
function logoutSession(string $base, string $accessToken, string $buildToken,
array $pinned): bool {
$clientNonce = bin2hex(random_bytes(8));
// On a DPoP bound session, add a DPoP header over this URL with ath over the
// access token. Without one a strict deployment answers 401 dpop_required,
// the logout does not happen, and the seat stays held.
$ch = curl_init("$base/v1/sess/logout");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"x-licentry-build: $buildToken",
'x-licentry-protocol: 1',
],
CURLOPT_POSTFIELDS => json_encode([
'accessToken' => $accessToken, 'clientNonce' => $clientNonce,
]),
]);
$h = [];
collectHeaders($ch, $h);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
// 200 {"ok":true}, never 204: a signature over an empty body proves nothing,
// so this route answers with a body, and the body carries the echo.
if (!licentryAccepted($code, $h, $raw, $pinned)) {
return false;
}
$b = json_decode($raw, true);
return ($b['clientNonce'] ?? null) === $clientNonce;
}
struct LogoutAck: Decodable { let ok: Bool?; let clientNonce: String? }
// Call this when the user quits. maxConcurrentSessions defaults to 1, and a
// session that is never logged out holds its seat until the refresh window
// closes, which is days: the customer's own next launch is what gets evicted.
func logout(base: URL, build: String, accessToken: String,
pinned: [String: P256.Signing.PublicKey]) async -> Bool {
var bytes = [UInt8](repeating: 0, count: 8)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
let clientNonce = bytes.map { String(format: "%02x", $0) }.joined()
// On a DPoP bound session, add a DPoP header over this URL with ath over the
// access token. Without one a strict deployment answers 401 dpop_required,
// the logout does not happen, and the seat stays held.
var req = URLRequest(url: base.appendingPathComponent("v1/sess/logout"))
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue(build, forHTTPHeaderField: "x-licentry-build")
req.setValue("1", forHTTPHeaderField: "x-licentry-protocol")
req.httpBody = try? JSONEncoder().encode(["accessToken": accessToken,
"clientNonce": clientNonce])
// 200 {"ok":true}, never 204: a signature over an empty body proves nothing,
// so this route answers with a body, and the body carries the echo.
guard let pair = try? await URLSession.shared.data(for: req),
let http = pair.1 as? HTTPURLResponse,
accepted(http, body: pair.0, pinned: pinned),
let ack = try? JSONDecoder().decode(LogoutAck.self, from: pair.0)
else { return false }
return ack.clientNonce == clientNonce
}
// Call this when the user quits. maxConcurrentSessions defaults to 1, and a
// session that is never logged out holds its seat until the refresh window
// closes, which is days: the customer's own next launch is what gets evicted.
fun logout(base: String, build: String, accessToken: String,
pinned: Map<String, PublicKey>): Boolean {
val clientNonce = ByteArray(8).also { SecureRandom().nextBytes(it) }
.joinToString("") { "%02x".format(it) }
val body = JSONObject()
.put("accessToken", accessToken)
.put("clientNonce", clientNonce)
.toString()
val req = Request.Builder()
// On a DPoP bound session, add a DPoP header over this URL with ath over
// the access token. Without one a strict deployment answers
// 401 dpop_required, the logout does not happen, and the seat stays held.
.url("$base/v1/sess/logout")
.addHeader("x-licentry-build", build)
.addHeader("x-licentry-protocol", "1")
.post(body.toRequestBody(JSON))
.build()
client.newCall(req).execute().use { res ->
val raw = res.body!!.bytes()
// 200 {"ok":true}, never 204: a signature over an empty body proves
// nothing, so this route answers with a body that carries the echo.
if (!accepted(res, raw, pinned)) return false
return JSONObject(String(raw)).optString("clientNonce") == clientNonce
}
}
# Call this when the user quits. maxConcurrentSessions defaults to 1, and a
# session that is never logged out holds its seat until the refresh window
# closes, which is days: the customer's own next launch is what gets evicted.
#
# On a DPoP bound session, add a DPoP header over this URL with ath over the
# access token. Without one a strict deployment answers 401 dpop_required, the
# logout does not happen, and the seat stays held.
def logout(base, build_token, access_token, pinned)
client_nonce = SecureRandom.hex(8)
res = Net::HTTP.post(
URI("#{base}/v1/sess/logout"),
{ accessToken: access_token, clientNonce: client_nonce }.to_json,
'Content-Type' => 'application/json',
'x-licentry-build' => build_token,
'x-licentry-protocol' => '1'
)
# 200 {"ok":true}, never 204: a signature over an empty body proves nothing,
# so this route answers with a body, and the body carries the echo.
status, body = verify!(res, res.body, pinned)
status == 200 && body['clientNonce'] == client_nonce
rescue StandardError
false
end
That's a working integration. It is not yet a protected one: a new product enforces nothing, so the next stop is switch the protections on, which is the settings on the product and the client-side checks that make them mean anything, each with the mistake people actually make written next to it.
What your client needs
There is no Licentry SDK to install. The client side is plain HTTPS and JSON, so the only question is whether your language can do the four primitives below. Most can do all of them without adding a dependency.
| Primitive | Used for | Needed when |
|---|---|---|
| HTTPS client | Every call | Always. TLS verification on, certificate checks left alone. |
| JSON encode/decode | Every request and response body | Always. |
| SHA-256 | Device hash, the digest inside the response signature, the DPoP ath claim | Always. |
| Base64 and base64url | Signature header, JWT parts, ath | Always. Note base64url is not plain base64: - and _ replace + and /, and padding is stripped. |
| ECDSA P-256 verify | Checking X-Licentry-Sig, and the offline grace JWT (ES256) | Whenever you verify what the server told you, which should be always. |
| ECDSA P-256 sign + keypair generation | DPoP proofs | Only if you bind sessions with DPoP. Skip it and everything else still works. |
| Secure local storage | Access and refresh tokens, and the licence key if you keep it | Always. Platform keychain or equivalent, never a plaintext file next to the binary. |
| Stable machine identifiers | Building the device hash | Always. See device binding for what makes a good one. |
Does your language need a package?
Only the P-256 verify step is ever in doubt. Everything else is standard library everywhere.
- Standard library, nothing to install: C#, Go, Python, Node.js, TypeScript, JavaScript, Java, Kotlin, Scala, Clojure, Groovy, Swift, Objective-C, PHP, Ruby, Elixir, Erlang, F#, Visual Basic, Zig, PowerShell.
- One well-known package: C and C++ (OpenSSL), Rust (
p256orring), Dart (pointycastle), Haskell (cryptonite), Perl (CryptX), Lua (luaossl), Delphi (TMS or LockBox), plus Nim, Crystal, OCaml, Julia and R through their usual bindings.
Godot deserves a specific warning. GDScript's Crypto class exposes RSA only, so a pure GDScript client can run activate, heartbeat and refresh but cannot verify the response signature without a GDExtension. That leaves the most important check undone, so treat Godot as needing native code rather than as supported out of the box.
On the server side, nothing
Your backend needs no Licentry software. If it checks entitlements it calls the Vendor API over HTTPS with an API key. Webhook receivers need HMAC-SHA256, which every language has built in.
Core concepts
Licence key format
Keys are 28 characters, four mixed-case alphanumeric groups: ^[a-zA-Z0-9]{7}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{6}-[a-zA-Z0-9]{7}$. Example (illustrative): aB3xK9m-7Qp2n-M4kL8z-Xy1Wq9r. Keys are HMAC-peppered at rest, so a database leak alone reveals nothing usable.
Device hash
A 64-character hex string that identifies one machine or install. Derive it from stable hardware/software identifiers on your platform and hash the result to 64 hex with a fixed recipe. The server validates length and charset (either case, it normalises to lower), and nothing more: the value is whatever your client computes and sends. So the device hash catches an accidentally copied token and an honest hardware change; it does not stop a deliberate attacker, who can send the first machine's hash from the second machine. DPoP is the mechanism that a copied state folder cannot defeat, because the private key never leaves the machine's keystore. Turn on requireDpop for your product and send dpopPublicJwk at activate. Send the same hash on activate, heartbeat and refresh; a refresh that leaves it out is refused with 400 device_required rather than skipping the check. Turning requireDpop on is one tick box, and what your client has to do differently is written out there.
Build token
The x-licentry-build header identifies the exact build of your client. Register each release you ship on the Client builds page of your dashboard, or with POST /v1/vendor/client-builds, and embed the token you get back. Send it on all five session routes: validate, activate, heartbeat, refresh and logout. An unrecognised, revoked or superseded token is answered with 426 upgrade_required on every one of them. Treat that as an update prompt, and as terminal for that binary: no retry can succeed. A missing token is a different case, and it is only refused when the product sets requireBuildToken.
Two things it does. It binds a request to one build of one account: a token from another Licentry account cannot activate your licences, and yours cannot activate theirs. And revoking it stops every copy of that build from getting a session, which is how you cut off a leaked or bypassed release without touching a single customer's licence.
It is not a secret and it does not authenticate your client. It ships inside the binary as plaintext and strings recovers it in seconds. Treat it as a revocable version number: never present it to your users as protection, never derive anything from it, and never store it anywhere you would not store a build number. Requiring one is a per-product switch, requireBuildToken. Leave it off while you are building the integration, and turn it on before you ship: until you do, revoking a leaked release does nothing, because the leaked binary can simply stop sending the header. Register releases under Client builds.
Revocation version
Each licence carries a revocationVersion. Bump it (revoke, freeze, or reset a device) and every session bound to the old version fails on its next heartbeat with stale_revocation. This is the kill switch: propagation is bounded by your heartbeat interval, not by token expiry.
Lifecycle state machine
The client integration is a small, strict state machine. This is the order your code runs in and the transitions you must handle.
On 401 stale_revocation or refresh_token_stale, always re-activate with the licence key. If you don't store the key in secure storage, the user re-enters it, so plan your UX for that path.
Those are two of eight. The diagram shows the transitions that end in a re-activation, and re-activating is the wrong answer for three of the other six: 401 expired usually wants a refresh, 401 dpop_* wants a fresh proof on the same beat, and 401 revoked is terminal. The full branch table is at failure handling, and it is worth reading before you write the error handler rather than after.
Endpoint reference
Every endpoint uses a strict JSON body: unknown properties are rejected with 400 Invalid request body. All bodies are Content-Type: application/json, and every response on these routes carries the signature headers from the quickstart.
The path prefix is deliberately generic. A shipped binary contains /v1/sess/… rather than anything with "license" in it, which is one less string for an attacker to hunt for. All routes are rate limited. On 429, back off with jitter, and honour the RateLimit-* headers when they are present. Not every limiter publishes them, so treat their absence as normal rather than as a reason to keep retrying. The exact ceilings are on the hardening guide in your dashboard; a client that beats every few minutes and backs off on 429 never meets any of them.
Read-only key check. Optionally enforces a cached revocation version. Requires the x-licentry-build header. Success is 200 with { "valid": true, "product": "…", "revocationVersion": n }, or a deliberately generic { "valid": false }.
| Field | Type | Req | Notes |
|---|---|---|---|
licenseKey | string | yes | Must match the key format. |
product | string | no | If the key belongs to another product the reply is the plain { valid:false }, with no reason. Every refusal here looks the same on purpose, so a caller cannot sort a stolen key list by which ones answer differently. |
clientRevocationVersion | number | no | If sent, must equal the DB version or the key reads invalid. |
clientNonce | string | no | 8 to 128 printable characters. Echoed back inside the signed body, which is how you tell this response apart from a replay of an older one. Send a fresh random value on every call. |
deviceHash | string | no | Accepted for compatibility and not acted on. Validate is read only, so device binding happens at activate. |
realIp | string | no | What the client believes its own address is. Recorded as telemetry and never trusted as the caller address. |
These six are the whole list. The body is strict, so any other field is a 400 rather than something ignored.
Creates a runtime session: access and refresh tokens, optionally DPoP-bound. Send an Idempotency-Key (≥ 8 chars) and reuse it when retrying after a network error.
| Field | Type | Req | Notes |
|---|---|---|---|
licenseKey | string | yes | Same 28-character format as validate. |
deviceHash | string | yes | 64 hex chars. |
dpopPublicJwk | string | no | JSON string of a P-256 JWK. Malformed → 403 dpop_jwk_invalid (fails closed). |
clientRevocationVersion | number | no | Must match current DB version if provided. |
Activation failures are deliberately indistinguishable. Unknown, expired, revoked and canary keys all return 400 activation_failed, held to the same padded response time so the clock tells you nothing either. Don't branch on the reason; it exists only in the server log, by design, so activation can't be used as an oracle.
Outcomes your client should handle separately: 403 too_many_devices, 403 license_suspended, 403 dpop_jwk_invalid, and 426 upgrade_required when the build token is present and unrecognised, retired or superseded. Everything else comes back as 400 activation_failed on a fixed delay, because telling those apart would let a caller sort a stolen key list into live and dead. That includes a key that belongs to a different product, a client below the product's minProtocolVersion, and a missing build token on a product that sets requireBuildToken. A deduplicated retry returns the same session with "idempotent": true.
A missing build token never gives 426 on activate. The two cases are answered differently on purpose and the difference is not symmetric across routes.
- Present and unrecognised, retired or superseded:
426 upgrade_requiredon all five session routes. That check runs before any licence is looked at, so it can afford to say what it is. Terminal for that binary: prompt for an update. - Missing, on a product with
requireBuildTokenon:400 activation_failedon activate and200 { "valid": false }on validate, both on the padded delay. On heartbeat and refresh it is426, because those calls are past authentication and the caller already holds a session for that licence, so naming the reason discloses nothing. Logout checks the token itself and never the product control, so a missing header is accepted there.
Whether a product requires a build token is reached through the licence, which is why the two routes that take a raw key fold it into their generic refusal. Treat 426 as "this binary needs replacing" and 400 activation_failed as "this key cannot start a session", and you are correct on both.
Extends the access window. Requires Authorization: Bearer <accessToken>, plus a DPoP proof if the session is bound. Success is 200 with { "ok": true, "expiresAt": "…", "revocationVersion": n, "serverNonce": "…", "clientNonce": "…" }, plus offlineGraceJwt, signingPublicKey and engineParams where they are configured. Schedule the next beat from the returned expiresAt.
| Field | Type | Req | Notes |
|---|---|---|---|
seq | number | yes | Integer 1 or greater. Rules below. |
deviceHash | string | yes | 64 hex, byte-identical to activate. Not optional: {"seq":1} alone is 400 on every beat. |
serverNonce | string | first beat no, then yes in practice | The value from the previous response. Empty on the first beat of a session. Once the server has issued one, omitting it counts as a mismatch. |
clientNonce | string | no | Fresh random per beat, echoed in the signed body. Send it and compare the echo. |
runId | string | no, recommended | 8 to 64 characters of A-Za-z0-9_-. See below. |
What runId is for. Generate one value per process start and send that same value on every beat for the life of that run. It is evidence rather than a control: it never causes a refusal, it never changes a response, and a missing value is simply no signal. What it detects is a relay, meaning one machine that holds a real session and proxies our answers to several other copies. An honest client contributes one runId per session; several distinct values on one session is what a relay looks like from our side. Hiding it forces the relay to parse and rewrite every request body, which requireDpopBody then breaks. It costs you a random string at startup, so send it.
| Rule | Behavior |
|---|---|
| First seq | Row starts at seq=0; first heartbeat must send seq: 1. |
| Monotonic | Next value must satisfy oldSeq < newSeq ≤ oldSeq + 8. |
| After refresh | Server resets seq to 0, so send seq: 1 again. It leaves the nonce chain alone, so echo the last serverNonce you hold; clearing it is 401 nonce_mismatch on the first beat after every refresh. |
409 stale_seq | You sent seq ≤ stored (replay). |
409 seq_gap | You skipped more than 8 (newSeq > oldSeq + 8). |
409 bad_seq | seq isn't a positive integer. |
401 stale_revocation | Licence revocation version changed → re-activate. |
Other 401 | invalid_session, expired, revoked, nonce_mismatch, dpop_* and a bodiless refusal for a missing bearer header all arrive as 401 with four different correct recoveries. Read the branch table rather than re-activating on all of them. |
Rotates the token pair before refreshExpiresAt. Body: { "refreshToken": "...", "deviceHash": "...", "clientNonce": "..." }. The refresh token is 32 to 512 characters. If DPoP-bound, send a proof whose ath hashes the refresh token. Success returns a fresh token pair with new expiries. After success, next heartbeat is seq: 1, and it still has to echo the serverNonce you were last issued: the refresh resets the sequence and not the nonce chain, so a client that clears the stored nonce gets 401 nonce_mismatch on the first beat after every refresh.
| Field | Type | Req | Notes |
|---|---|---|---|
refreshToken | string | yes | 32 to 512 characters. |
deviceHash | string | optional in the schema, required in practice | 64 hex, the same value you sent at activate. Leaving it out is 400 device_required whenever the session is DPoP-bound, and on any deployment that sets LICENSE_REFRESH_REQUIRE_DEVICE, which Licentry Cloud does. A value that does not match revokes the session with 403 license_suspended. Both cases are below. |
clientNonce | string | no | 8 to 128 printable characters, echoed in the signed body. Send it and compare the echo. |
These three are the whole list. The body is strict, so any other field is a 400.
Send the device hash. It is optional in the schema so that clients built before it existed keep working, and whether omitting it is refused is a deployment setting rather than a fixed behaviour:
| Setting | Default | Licentry Cloud | What it changes |
|---|---|---|---|
LICENSE_REFRESH_REQUIRE_DEVICE | off | on | Off, deviceHash is enforced when present and not demanded. On, a refresh without it is 400 device_required and nothing rotates. |
A DPoP-bound session demands the hash whatever that setting says, because nothing older than the binding can exist. So against Licentry Cloud a client that omits the field can never refresh. Send it unconditionally and the setting stops mattering.
Refresh carries no key material, and that has a consequence. Activate and heartbeat both return signingPublicKey, the JWK that matches the offline grace token. Refresh returns neither that nor a fresh offlineGraceJwt. A client that goes idle for a long stretch and only refreshes therefore cannot follow an offline grace key rotation from refresh alone, and holds a grace token that ages out with nothing replacing it. Send at least one heartbeat after every refresh before you rely on the offline path. It is the seq: 1 beat you were going to send anyway.
Revokes the session for an access token. Body: { "accessToken": "...", "clientNonce": "..." }. Returns 200 with { "ok": true } and your echoed nonce. It is not a 204: a response with no body cannot carry the nonce echo, and every signed empty body on these routes would otherwise be interchangeable.
Call it whenever the user quits, signs out or switches account, on the last clean shutdown path you control, and treat it as fire-and-forget rather than something a quit waits on.
What happens if you never call it. The session holds its concurrency seat until its refresh window closes, which is days rather than minutes. maxConcurrentSessions defaults to 1, so on a default product the seat your last run abandoned is the seat your customer's next launch needs. It does get taken, because the cap evicts the oldest session rather than refusing the new one, so the customer's own next launch evicts their own dead session and the product works. The cost is what the eviction writes: every one is stamped as superseded and counted by the sharing evidence on that licence. A client that never logs out therefore produces a slow drip of eviction evidence against customers who have done nothing, which is exactly the signal you would want to trust when it does mean something.
A crash cannot log out, and that is fine: the seat is held until the window closes and the eviction path covers it. What is not fine is skipping the call on a clean exit.
If the session is DPoP-bound, send a proof over the logout URL with ath of the access token. Without one, a leaked access token is enough to end a customer's session, which is why Licentry Cloud provisions LICENSE_LOGOUT_DPOP_STRICT (off in the code default): a bound session that presents no proof, or one that fails to verify, is refused with 401 dpop_required instead of being recorded and allowed through. The refusal means the logout did not happen and the seat stays held, so a client that cannot build a proof cannot release its own seat.
Public JWK set (P-256 / ES256) for verifying the offline-grace tokens issued against your licences: 200 with { "keys": [ … ] }, or 404 offline_grace_not_configured. The set carries two keys while a rotation is in flight, the one signing today first, and only that first one signs. Unauthenticated, because these are public keys. Your account id and the exact URL are on the Signing keys page of your dashboard and in the jwks block of GET /v1/vendor/me. This is the set to pin.
The deployment key, which signs only licences that belong to no vendor account. A licence issued from your account is never signed with it, so a client that verifies against this set alone rejects every token it receives. Ignore it unless you were told otherwise. It carries two keys during a deployment key rotation, on the same rule: the first one signs.
DPoP proofs
DPoP (proof-of-possession, RFC 9449) binds a session to a private key the client holds. Register a P-256 public JWK at activate, then send a fresh signed proof on every heartbeat and refresh. A stolen token becomes useless without the key.
Registering the key (activate)
Send dpopPublicJwk as a JSON string whose parsed value is a P-256 JWK (kty:"EC", crv:"P-256", base64url x/y).
The proof JWT
- Algorithm
ES256, signed with the private key matching the registered JWK. - Claims
iatandexprequired. Proof no older than 120s; lifetimeexp - iat≤ 120s (± ~30s skew). - htm
POST; htu the public origin plus the path, with no query string (for examplehttps://licentry.cc/v1/sess/heartbeat). Build it from the origin you were given, not from the host your process happened to connect to. If your client reaches us under some other name, through a proxy that rewritesHost, a staging alias or a bare IP, the two differ and every proof comes back401 dpop_htu_mismatch, which reads like a bug in your JWT and is not one. Either path family works, so/v1/sess/heartbeatand/api/public/license/heartbeatboth verify; only the origin is fixed. - ath base64url(SHA-256(token)): the access token on heartbeat, the refresh token on refresh.
- jti a unique string (≥ 8 chars); not reused within the 150 second replay window. Generate it from a random source rather than a counter.
- bh base64url(SHA-256(request body bytes)), the same construction as
athbut over what you are about to send. Optional in the sense that a client built before it existed omits it, and verified whenever it is present: abhthat does not match the bytes received is401 dpop_bh_mismatch. Hash the exact bytes you put on the wire, not a re-serialisation, because two JSON encoders disagree about key order and spacing. Making its absence a refusal is therequireDpopBodyswitch.
Generating the key and the proof
One keypair per install, generated once and kept in the platform keystore, then a fresh proof on every request. Watch the signature format: JOSE expects the raw r||s bytes, and most libraries hand you DER by default. Each example below does that conversion.
# One P-256 key per install, generated once and kept in the OS keystore.
openssl ecparam -name prime256v1 -genkey -noout -out dpop.pem
# Public JWK for activate. dpopPublicJwk is a JSON STRING, not an object.
JWK=$(openssl ec -in dpop.pem -pubout -outform DER 2>/dev/null | tail -c 64 | \
xxd -p -c 64 | awk '{ x=substr($0,1,64); y=substr($0,65,64);
cmd="printf %s " x " | xxd -r -p | basenc --base64url | tr -d =";
cmd | getline X; close(cmd);
cmd="printf %s " y " | xxd -r -p | basenc --base64url | tr -d =";
cmd | getline Y; close(cmd);
printf "{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"%s\",\"y\":\"%s\"}", X, Y }')
# The build header belongs on this call like any other session route. Without it
# a product with require_build_token on answers a deliberately generic
# activation_failed, which looks like a bad licence key and is very hard to
# diagnose from the client side.
curl -sS -X POST "$BASE/v1/sess/activate" -H "Content-Type: application/json" \
-H "x-licentry-build: $BUILD_TOKEN" \
-H "x-licentry-protocol: 1" \
-d "$(jq -cn --arg k "$LICENSE_KEY" --arg d "$DEVICE_HASH" --arg j "$JWK" \
--arg n "$(openssl rand -hex 8)" \
'{licenseKey:$k, deviceHash:$d, dpopPublicJwk:$j, clientNonce:$n}')"
# Every later heartbeat/refresh needs a fresh proof. ath binds it to the token,
# jti makes it single use, and the server refuses a lifetime over 120s.
# On /v1/sess/refresh feed ath the REFRESH token, not the access token.
ath() { printf %s "$1" | openssl dgst -binary -sha256 | basenc --base64url | tr -d =; }
# Sign with a JOSE library: ES256 needs raw r||s, and openssl emits DER.
// Windows: hold the key in CNG (BCRYPT_ECDSA_P256_ALGORITHM) so it is never
// a file on disk. BCryptSignHash already returns raw r||s, which is what JOSE
// wants, so no DER unwrapping is needed here.
std::string b64url(const uint8_t* p, size_t n); // no padding
std::string sha256B64Url(const std::string& s);
// Sent ONCE, at activate, as a JSON *string* in dpopPublicJwk.
std::string dpopPublicJwk(const std::vector<uint8_t>& x, const std::vector<uint8_t>& y) {
return "{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"" +
b64url(x.data(), 32) + "\",\"y\":\"" + b64url(y.data(), 32) + "\"}";
}
// One fresh proof per request. Reusing one is a replay: the server remembers jti.
// On /v1/sess/refresh pass the refresh token here: ath covers the token the call presents.
std::string dpopProof(BCRYPT_KEY_HANDLE key, const std::string& htm,
const std::string& htu, const std::string& accessToken) {
const long now = (long)time(nullptr);
std::string hdr = R"({"alg":"ES256","typ":"dpop+jwt"})";
std::string body = "{\"htm\":\"" + htm + "\",\"htu\":\"" + htu +
"\",\"ath\":\"" + sha256B64Url(accessToken) +
"\",\"jti\":\"" + randomHex(16) +
"\",\"iat\":" + std::to_string(now) +
",\"exp\":" + std::to_string(now + 60) + "}";
std::string signingInput = b64urlStr(hdr) + "." + b64urlStr(body);
std::vector<uint8_t> rs = signP256(key, sha256Raw(signingInput)); // 64 bytes
return signingInput + "." + b64url(rs.data(), rs.size());
}
// Send as the DPoP request header. htu must be the exact URL you POST to.
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
static string B64Url(byte[] b) => Convert.ToBase64String(b)
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
// Generate once per install and persist the key, not the JWK.
static ECDsa NewDpopKey() => ECDsa.Create(ECCurve.NamedCurves.nistP256);
// dpopPublicJwk is a JSON STRING in the activate body.
static string PublicJwk(ECDsa key) {
var p = key.ExportParameters(false);
return JsonSerializer.Serialize(new {
kty = "EC", crv = "P-256", x = B64Url(p.Q.X!), y = B64Url(p.Q.Y!)
});
}
// A fresh proof per request; jti is remembered server side, so never reuse one.
// On /v1/sess/refresh pass the refresh token here: ath covers the token the call presents.
static string DpopProof(ECDsa key, string htm, string htu, string accessToken) {
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
string hdr = B64Url(Encoding.UTF8.GetBytes(
JsonSerializer.Serialize(new { alg = "ES256", typ = "dpop+jwt" })));
string ath = B64Url(SHA256.HashData(Encoding.UTF8.GetBytes(accessToken)));
string body = B64Url(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new {
htm, htu, ath, jti = Guid.NewGuid().ToString("N"),
iat = now, exp = now + 60 // the server refuses a window over 120s
})));
string input = hdr + "." + body;
// IeeeP1363 is raw r||s, which is what JOSE expects. The default is DER.
byte[] sig = key.SignData(Encoding.UTF8.GetBytes(input), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
return input + "." + B64Url(sig);
}
// req.Headers.Add("DPoP", DpopProof(key, "POST", url, accessToken));
import base64
import hashlib
import json
import secrets
import time
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, utils
def b64u(b: bytes) -> str:
return base64.urlsafe_b64encode(b).decode().rstrip("=")
def new_dpop_key():
return ec.generate_private_key(ec.SECP256R1()) # once per install, then stored
def public_jwk(key) -> str:
n = key.public_key().public_numbers()
# A JSON STRING is what activate expects, not a nested object.
return json.dumps({"kty": "EC", "crv": "P-256",
"x": b64u(n.x.to_bytes(32, "big")),
"y": b64u(n.y.to_bytes(32, "big"))}, separators=(",", ":"))
# On /v1/sess/refresh pass the refresh token here: ath covers the token the call presents.
def dpop_proof(key, htm: str, htu: str, access_token: str) -> str:
now = int(time.time())
hdr = b64u(json.dumps({"alg": "ES256", "typ": "dpop+jwt"},
separators=(",", ":")).encode())
body = b64u(json.dumps({
"htm": htm, "htu": htu,
"ath": b64u(hashlib.sha256(access_token.encode()).digest()),
"jti": secrets.token_hex(16), # single use; the server remembers it
"iat": now, "exp": now + 60, # a window over 120s is refused
}, separators=(",", ":")).encode())
signing_input = f"{hdr}.{body}".encode()
der = key.sign(signing_input, ec.ECDSA(hashes.SHA256()))
r, s = utils.decode_dss_signature(der) # JOSE wants raw r||s, not DER
raw = r.to_bytes(32, "big") + s.to_bytes(32, "big")
return f"{hdr}.{body}.{b64u(raw)}"
# headers={"DPoP": dpop_proof(key, "POST", url, access_token)}
import { webcrypto as crypto } from "node:crypto";
const b64u = (b) => Buffer.from(b).toString("base64url");
// Generate once, then persist the key. extractable:false keeps the private half
// out of your own process memory as a serialisable value.
export async function newDpopKey() {
return crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" },
false, ["sign"]);
}
// dpopPublicJwk is a JSON STRING in the activate body, not an object.
export async function publicJwk(pair) {
const j = await crypto.subtle.exportKey("jwk", pair.publicKey);
return JSON.stringify({ kty: "EC", crv: "P-256", x: j.x, y: j.y });
}
// On /v1/sess/refresh pass the refresh token here: ath covers the token the call presents.
export async function dpopProof(pair, htm, htu, accessToken) {
const now = Math.floor(Date.now() / 1000);
const hdr = b64u(JSON.stringify({ alg: "ES256", typ: "dpop+jwt" }));
const ath = b64u(await crypto.subtle.digest("SHA-256",
new TextEncoder().encode(accessToken)));
const body = b64u(JSON.stringify({
htm, htu, ath,
jti: crypto.randomUUID(), // single use; the server remembers it
iat: now, exp: now + 60, // the server refuses a window over 120s
}));
const input = new TextEncoder().encode(hdr + "." + body);
// WebCrypto ECDSA output is already raw r||s, which is what JOSE expects.
const sig = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" },
pair.privateKey, input);
return hdr + "." + body + "." + b64u(sig);
}
// headers: { DPoP: await dpopProof(pair, "POST", url, accessToken) }
// Generate once per install with ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
// and persist it in the OS keystore.
func b64u(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
// dpopPublicJwk is a JSON STRING in the activate body.
func PublicJWK(k *ecdsa.PrivateKey) string {
x := make([]byte, 32)
y := make([]byte, 32)
k.X.FillBytes(x)
k.Y.FillBytes(y)
return fmt.Sprintf(`{"kty":"EC","crv":"P-256","x":"%s","y":"%s"}`, b64u(x), b64u(y))
}
// On /v1/sess/refresh pass the refresh token here: ath covers the token the call presents.
func DpopProof(k *ecdsa.PrivateKey, htm, htu, accessToken string) (string, error) {
now := time.Now().Unix()
hdr := b64u([]byte(`{"alg":"ES256","typ":"dpop+jwt"}`))
sum := sha256.Sum256([]byte(accessToken))
jti := make([]byte, 16)
rand.Read(jti)
claims, _ := json.Marshal(map[string]any{
"htm": htm, "htu": htu, "ath": b64u(sum[:]),
"jti": hex.EncodeToString(jti), // single use; the server remembers it
"iat": now, "exp": now + 60, // a window over 120s is refused
})
input := hdr + "." + b64u(claims)
digest := sha256.Sum256([]byte(input))
r, s, err := ecdsa.Sign(rand.Reader, k, digest[:])
if err != nil {
return "", err
}
// JOSE wants fixed-width raw r||s, not the ASN.1 form ecdsa.SignASN1 gives.
rs := make([]byte, 64)
r.FillBytes(rs[:32])
s.FillBytes(rs[32:])
return input + "." + b64u(rs), nil
}
// req.Header.Set("DPoP", proof)
use p256::ecdsa::{signature::Signer, Signature, SigningKey};
use sha2::{Digest, Sha256};
fn b64u(b: &[u8]) -> String { URL_SAFE_NO_PAD.encode(b) }
// SigningKey::random(&mut OsRng) once per install, then persist it.
pub fn public_jwk(key: &SigningKey) -> String {
let pt = key.verifying_key().to_encoded_point(false);
// dpopPublicJwk is a JSON STRING in the activate body.
format!(
r#"{{"kty":"EC","crv":"P-256","x":"{}","y":"{}"}}"#,
b64u(pt.x().unwrap()), b64u(pt.y().unwrap())
)
}
// On /v1/sess/refresh pass the refresh token here: ath covers the token the call presents.
pub fn dpop_proof(key: &SigningKey, htm: &str, htu: &str, access_token: &str) -> String {
let now = Utc::now().timestamp();
let hdr = b64u(br#"{"alg":"ES256","typ":"dpop+jwt"}"#);
let ath = b64u(&Sha256::digest(access_token.as_bytes()));
let jti: [u8; 16] = rand::random();
let claims = serde_json::json!({
"htm": htm, "htu": htu, "ath": ath,
"jti": hex::encode(jti), // single use; the server remembers it
"iat": now, "exp": now + 60, // a window over 120s is refused
});
let input = format!("{hdr}.{}", b64u(claims.to_string().as_bytes()));
// p256 signs to fixed-width r||s already, which is what JOSE expects.
let sig: Signature = key.sign(input.as_bytes());
format!("{input}.{}", b64u(&sig.to_bytes()))
}
// .header("DPoP", dpop_proof(&key, "POST", url, access_token))
// KeyPairGenerator.getInstance("EC") with an ECGenParameterSpec of "secp256r1",
// generated once per install and kept in the platform KeyStore.
static String b64u(byte[] b) { return Base64.getUrlEncoder().withoutPadding().encodeToString(b); }
// dpopPublicJwk is a JSON STRING in the activate body.
static String publicJwk(ECPublicKey pub) {
byte[] x = toFixed32(pub.getW().getAffineX());
byte[] y = toFixed32(pub.getW().getAffineY());
return "{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"" + b64u(x)
+ "\",\"y\":\"" + b64u(y) + "\"}";
}
// On /v1/sess/refresh pass the refresh token here: ath covers the token the call presents.
static String dpopProof(PrivateKey key, String htm, String htu, String accessToken)
throws Exception {
long now = Instant.now().getEpochSecond();
String hdr = b64u("{\"alg\":\"ES256\",\"typ\":\"dpop+jwt\"}".getBytes(UTF_8));
String ath = b64u(MessageDigest.getInstance("SHA-256")
.digest(accessToken.getBytes(UTF_8)));
String claims = String.format(
"{\"htm\":\"%s\",\"htu\":\"%s\",\"ath\":\"%s\",\"jti\":\"%s\",\"iat\":%d,\"exp\":%d}",
htm, htu, ath, UUID.randomUUID().toString().replace("-", ""), now, now + 60);
String input = hdr + "." + b64u(claims.getBytes(UTF_8));
// The P1363 variant emits raw r||s; plain SHA256withECDSA would emit DER.
Signature s = Signature.getInstance("SHA256withECDSAinP1363Format");
s.initSign(key);
s.update(input.getBytes(UTF_8));
return input + "." + b64u(s.sign());
}
// .header("DPoP", dpopProof(key, "POST", url, accessToken))
<?php
// openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC])
// once per install, then persist the PEM outside the web root.
function b64u(string $b): string { return rtrim(strtr(base64_encode($b), '+/', '-_'), '='); }
// dpopPublicJwk is a JSON STRING in the activate body.
function publicJwk($key): string {
$d = openssl_pkey_get_details($key);
return json_encode([
'kty' => 'EC', 'crv' => 'P-256',
'x' => b64u(str_pad($d['ec']['x'], 32, "\0", STR_PAD_LEFT)),
'y' => b64u(str_pad($d['ec']['y'], 32, "\0", STR_PAD_LEFT)),
]);
}
// On /v1/sess/refresh pass the refresh token here: ath covers the token the call presents.
function dpopProof($key, string $htm, string $htu, string $accessToken): string {
$now = time();
$hdr = b64u(json_encode(['alg' => 'ES256', 'typ' => 'dpop+jwt']));
$claims = b64u(json_encode([
'htm' => $htm, 'htu' => $htu,
'ath' => b64u(hash('sha256', $accessToken, true)),
'jti' => bin2hex(random_bytes(16)), // single use; the server remembers it
'iat' => $now, 'exp' => $now + 60, // a window over 120s is refused
]));
$input = $hdr . '.' . $claims;
openssl_sign($input, $der, $key, OPENSSL_ALGO_SHA256);
// openssl emits DER; JOSE needs raw r||s, so unwrap the ASN.1 sequence.
return $input . '.' . b64u(p1363FromDer($der));
}
// CURLOPT_HTTPHEADER => ['DPoP: ' . dpopProof($key, 'POST', $url, $accessToken)]
import CryptoKit
import Foundation
func b64u(_ d: Data) -> String {
d.base64EncodedString().replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
}
// P256.Signing.PrivateKey() once per install, stored in the Keychain.
func publicJwk(_ key: P256.Signing.PrivateKey) -> String {
let raw = key.publicKey.rawRepresentation // x || y, 32 bytes each
// dpopPublicJwk is a JSON STRING in the activate body.
return "{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"\(b64u(raw.prefix(32)))\",\"y\":\"\(b64u(raw.suffix(32)))\"}"
}
// On /v1/sess/refresh pass the refresh token here: ath covers the token the call presents.
func dpopProof(_ key: P256.Signing.PrivateKey, htm: String, htu: String,
accessToken: String) throws -> String {
let now = Int(Date().timeIntervalSince1970)
let hdr = b64u(Data(#"{"alg":"ES256","typ":"dpop+jwt"}"#.utf8))
let ath = b64u(Data(SHA256.hash(data: Data(accessToken.utf8))))
let jti = UUID().uuidString.replacingOccurrences(of: "-", with: "")
// exp - iat over 120s is refused; jti is single use.
let claims = #"{"htm":"#(htm)","htu":"#(htu)","ath":"#(ath)","jti":"#(jti)","iat":#(now),"exp":#(now + 60)}"#
let input = hdr + "." + b64u(Data(claims.utf8))
let sig = try key.signature(for: Data(input.utf8))
// rawRepresentation is r||s, which is what JOSE expects.
return input + "." + b64u(sig.rawRepresentation)
}
// req.setValue(proof, forHTTPHeaderField: "DPoP")
// KeyPairGenerator.getInstance("EC") with "secp256r1", generated once and kept
// in the Android Keystore.
fun b64u(b: ByteArray): String = Base64.getUrlEncoder().withoutPadding().encodeToString(b)
// dpopPublicJwk is a JSON STRING in the activate body.
fun publicJwk(pub: ECPublicKey): String {
val x = b64u(toFixed32(pub.w.affineX))
val y = b64u(toFixed32(pub.w.affineY))
return """{"kty":"EC","crv":"P-256","x":"$x","y":"$y"}"""
}
// On /v1/sess/refresh pass the refresh token here: ath covers the token the call presents.
fun dpopProof(key: PrivateKey, htm: String, htu: String, accessToken: String): String {
val now = Instant.now().epochSecond
val hdr = b64u("""{"alg":"ES256","typ":"dpop+jwt"}""".toByteArray())
val ath = b64u(MessageDigest.getInstance("SHA-256").digest(accessToken.toByteArray()))
val jti = UUID.randomUUID().toString().replace("-", "")
// exp - iat over 120s is refused; jti is single use.
val exp = now + 60
val claims = """{"htm":"$htm","htu":"$htu","ath":"$ath","jti":"$jti","iat":$now,"exp":$exp}"""
val input = hdr + "." + b64u(claims.toByteArray())
// The P1363 name emits raw r||s; plain SHA256withECDSA would emit DER.
val s = Signature.getInstance("SHA256withECDSAinP1363Format")
s.initSign(key)
s.update(input.toByteArray())
return input + "." + b64u(s.sign())
}
// .header("DPoP", dpopProof(key, "POST", url, accessToken))
require "openssl"
require "json"
require "securerandom"
require "base64"
def b64u(b) = Base64.urlsafe_encode64(b, padding: false)
# OpenSSL::PKey::EC.generate("prime256v1") once per install, then persist it.
def public_jwk(key)
pt = key.public_key.to_bn.to_s(2) # 0x04 || x || y
# dpopPublicJwk is a JSON STRING in the activate body.
JSON.generate({ kty: "EC", crv: "P-256",
x: b64u(pt[1, 32]), y: b64u(pt[33, 32]) })
end
# On /v1/sess/refresh pass the refresh token here: ath covers the token the call presents.
def dpop_proof(key, htm, htu, access_token)
now = Time.now.to_i
hdr = b64u(JSON.generate({ alg: "ES256", typ: "dpop+jwt" }))
claims = b64u(JSON.generate({
htm: htm, htu: htu,
ath: b64u(OpenSSL::Digest::SHA256.digest(access_token)),
jti: SecureRandom.hex(16), # single use; the server remembers it
iat: now, exp: now + 60, # a window over 120s is refused
}))
input = "#{hdr}.#{claims}"
der = key.sign(OpenSSL::Digest.new("SHA256"), input)
# OpenSSL emits DER; JOSE needs raw r||s, so unwrap the ASN.1 sequence.
r, s = OpenSSL::ASN1.decode(der).value.map { _1.value }
raw = [r.to_s(16).rjust(64, "0"), s.to_s(16).rjust(64, "0")].join
"#{input}.#{b64u([raw].pack("H*"))}"
end
# req["DPoP"] = dpop_proof(key, "POST", url, access_token)
curl -sS -X POST "$BASE/v1/sess/heartbeat" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "DPoP: $DPOP_JWT" \
-d "{\"seq\":2,\"deviceHash\":\"$DEVICE_HASH\",\"serverNonce\":\"$LAST_SERVER_NONCE\"}"
Generate a new proof per request with a fresh jti, and keep the client clock within ~30s of UTC. If a proof is rejected for a timing reason, resync the clock and send a fresh proof rather than retrying the old one.
Offline grace
When configured, activate and every heartbeat return an offlineGraceJwt, a short-lived signed offline entitlement your app interprets locally while the network is gone. Key type is ES256 / P-256, so a Windows client verifies with native BCrypt.
Step 1: pin the right key, at build time
Two JWKS endpoints exist and they are not interchangeable. Pinning the wrong one means every token you receive fails verification, on every machine, forever.
- A licence that belongs to your account is signed with your account's key:
GET /v1/vendor/keys/<your account id>/offline-grace-jwks. There is no fallback. If that key cannot be used, theofflineGraceJwtfield is simply absent rather than signed with the shared platform key, because signing one account's entitlement with a key everyone shares is worse than issuing none. This is the set to pin. GET /v1/sess/offline-grace-jwksserves the deployment key, which signs only licences that belong to no vendor account. Your licences are never signed with it. Ignore it unless you were told otherwise.
Fetch once, at build time, and bake in every key the endpoint returns as a kid to key map. Never fetch at runtime. Rule 1 from the quickstart applies here harder than anywhere else: the offline path runs precisely when the network is hostile or absent, which is when a fetched key is whatever the machine's owner wants it to be. The JWT header carries a kid, so select the verifier from it, and keep every key the endpoint returns in your pin set.
Rotating your key takes two steps, and the gap between them is yours. Rotate publishes a new key beside the current one and signs nothing with it, so the endpoint starts returning two. Pin and ship is yours: re-fetch, bake both in, release, wait for it to reach your customers. Promote retires the old key and stops serving it, and from there the new key signs. Promote before you have shipped and anything in the field pinning only the old kid loses offline grace at its next heartbeat, with no fix short of another release.
Step 2: verify, then enforce four claims
Verify the ES256 signature against the pinned key first. Only then read the claims, and enforce all four before you honour the token:
devequals the device hash you compute locally, compared case-insensitively.exphas not passed.rvmatches the revocation version you last saw. A bumped version means the licence changed under you.prodis the product this binary is for, andtypislicentry_offline_grace.
There is no token without dev. The signer takes the device hash as a required argument and always writes the claim, so a token that lacks it did not come from us and must be refused rather than treated as an older, unbound grant.
Step 3: know what you are trusting
Two limits are worth stating to your own team rather than papering over. An issued grace token cannot be revoked: it stays honourable until it expires. And offline you are checking exp against a clock the customer controls, which is all anyone can do offline. Both are accepted design limits, and the mitigation is the length of the window, which is a platform setting rather than yours. Do not widen either by adding your own tolerance on top.
You must enforce device binding
The token's dev claim is the device hash it was minted for. Before honouring a stored token, compare dev against the hash you compute locally, case-insensitively, and refuse if they differ.
Skip the dev check and the token becomes a bearer entitlement: copying the app-state directory to another machine keeps working offline for the full TTL. TTL bounds how long a revoked licence runs offline; the dev check is what stops cross-machine sharing. Enforce both.
| Claim | Meaning |
|---|---|
typ | "licentry_offline_grace" |
lic | Licence id (UUID). |
prod | Product slug. |
rv | Revocation version. Enforce it in app policy. |
dev | Device hash (lowercase hex). Compare before honouring. |
Switch the protections on
What decides whether your licence check is worth anything falls into two groups. Settings on the product, which a product you have never opened enforces none of. And code in your client, which nothing on our side can do for you. The list grows as we ship controls, so read the table rather than a number you remember. Each entry below is: what it does, how to turn it on, what changes in your client, and the mistake people actually make.
| Protection | Where | Default | Answers |
|---|---|---|---|
requireBuildToken | Product setting | off | A leaked release you want to cut off. |
requireDpop | Product setting | off | A copied installation folder, and a stolen token. |
requireDpopBody | Product setting | off | A proxy on the customer's machine rewriting the body under a genuine proof. |
maxConcurrentSessions | Product setting | 1 | One key running in several places at once. |
engineParams | Product setting | unset | A patched binary that never calls us at all. |
payloadKey | Product setting | unset | The same, but it makes the copy unable to open its own files. |
vendorResponseSigning | Product setting | off | Someone else's key compromise being used against you. |
minProtocolVersion | Product setting | 0 | Old clients that never send the fields the rest of this table needs. |
| Response signature check | Your client | your job | A fake server on the customer's own machine. |
clientNonce echo | Your client | your job | One captured signed 200 replayed forever. |
| Heartbeat nonce chain | Your client | your job | A recorded heartbeat sequence replayed later. |
The product settings live in one place: Products, your product, then the Runtime protection and Engine parameters panels, which is /dashboard/products/<id>#protection. That page shows a score so you can see at a glance what a product enforces. Build tokens are registered separately under Client builds. Everything on that page is also reachable through PATCH /v1/vendor/products/:id, documented in the Vendor API reference. Changing a runtime protection is restricted to the account owner and admin seats, so a leaked developer key cannot quietly turn the defences off.
requireBuildToken
What it does. Refuses any session call that arrives without an x-licentry-build header, which is what makes revoking a leaked release actually stop that release.
Turn it on. Register the release under Client builds and embed the lb_… token you get back, then tick Require a build token on the product. In the API: POST /v1/vendor/client-builds to mint, then PATCH /v1/vendor/products/:id with { "requireBuildToken": true }. Pass productId when you mint so a token lifted from one binary cannot act on another.
What your client does. Sends x-licentry-build: <token> on all five session routes: validate, activate, heartbeat, refresh and logout. Treat 426 upgrade_required as terminal for that binary and prompt for an update, because no retry can succeed.
The mistake. Registering builds, shipping the header, and never ticking the box. Until the switch is on, a missing header is accepted, so the leaked binary you just revoked simply stops sending it and keeps working. Register first, ship a build that sends it, then turn the switch on. The order matters, because turning it on before your fleet sends the header locks out paying customers.
requireDpop
What it does. Refuses to mint or renew a session that is not bound to a private key held on the customer's machine, so a copied token or a copied installation folder is not enough to run.
Turn it on. Tick Require proof of possession on the product, or PATCH /v1/vendor/products/:id with { "requireDpop": true }. Ship a client that sends the key first: turning it on also refuses to renew existing unbound sessions, which sends every one of those clients back through activate.
What your client does. Generates one P-256 keypair per install in the platform keystore, sends the public half as dpopPublicJwk at activate as a JSON string, then signs a fresh ES256 proof on every heartbeat, refresh and logout. The full claim set is in DPoP proofs. Activate answers 403 dpop_required if the switch is on and you send no key, and 403 dpop_jwk_invalid if the key is malformed, so binding never fails open on a serialization bug.
The mistake. Generating the keypair into a file next to the binary. This control is exactly as strong as the place you put the private key: TPM-backed CNG, Keychain, Keystore or libsecret makes a copied folder useless, a key.pem in the app directory makes it copyable along with everything else. The second most common mistake is hashing the wrong token into ath: the access token on heartbeat and logout, the refresh token on refresh.
requireDpopBody, body binding on the proof
What it does. A proof carries htm, htu and ath, which cover the method, the URL and the token, and say nothing about what was in the body. A proxy on the customer's own machine can therefore rewrite deviceHash or seq and forward the client's genuine proof untouched. Adding the bh claim closes that, and this switch is what makes a proof without one unacceptable.
Turn it on. Ship a client that sends bh first, then PATCH /v1/vendor/products/:id with { "requireDpopBody": true }, or tick the control on the product. Off by default, because a client built before bh existed sends none.
What your client does. Adds bh, base64url(SHA-256(body bytes)), to every proof, over the exact bytes it is about to send. On heartbeat and refresh the server refuses a bound session whose proof carries no bh with 401 dpop_bh_required, and a bh that does not match the bytes received is 401 dpop_bh_mismatch whether or not the switch is on.
The mistake. Hashing the object rather than the bytes. Build the body string once, hash that string, then send that same string. Serialise twice and the two encoders disagree about key order or spacing, and every proof fails with a mismatch that looks like an attack in your logs.
maxConcurrentSessions
What it does. Caps how many live sessions one licence may hold at once, evicting the oldest by last heartbeat when a new activation goes over.
Turn it on. Set Concurrent sessions on the product, any value from 1 to 20, or PATCH /v1/vendor/products/:id with { "maxConcurrentSessions": 1 }. Leave it empty to inherit the deployment default of 1.
What your client does. Handles 401 invalid_session by re-activating once, with backoff. That status means the session was superseded by an activation elsewhere on the same licence, which is the normal outcome of a customer moving machines.
The mistake. Expecting it to refuse a second activation. It does not: it evicts. Two people sharing a key get an application that keeps quietly interrupting itself, which is deliberate, and a client that re-activates in a tight loop on 401 turns that into a rate-limit ban for your honest customer. This is also the one control people confuse with the device cap, which does refuse, with 403 too_many_devices. Different setting, opposite behaviour.
engineParams
What it does. Delivers a blob of values your software needs in order to run, encrypted so that only a client which completed a real activation can open it. This is the one control that survives someone patching your licence check out, because there is no check to patch: there is a decryption that either produces your parameters or produces nothing.
Turn it on. Put a JSON object in Engine parameters on the product, up to 16 KB, or PATCH /v1/vendor/products/:id with an engineParams object. It is sealed at rest under your account's key and never returned to anyone, including you, so keep your own copy. Submit an empty box or pass null to clear it.
What your client does. Reads the engineParams field from activate, heartbeat and refresh, derives the key, opens the blob, and feeds the result into the code that does the work. The recipe:
engineKey = HKDF-SHA256(
ikm = accessToken bytes, # UTF-8, the CURRENT token
salt = deviceHash bytes, # LOWERCASE hex, what the server salts with
info = "am-engine-params-v1",
len = 32)
blob = base64_decode(response.engineParams)
iv = blob[0 .. 11] # 12 bytes
tag = blob[12 .. 27] # 16 bytes
ct = blob[28 .. ]
config = json_parse(aes_256_gcm_open(engineKey, iv, tag, ct)) # throws on tamper
The mistake. Sealing a boolean. This is worth a worked example, because it is the difference between real tamper resistance and an expensive no-op.
# BAD. Every one of these is a value the client checks and then discards.
{
"licensed": true, # a sealed boolean is still a boolean
"tier": "pro", # one string compare to patch
"expiryCheck": true, # the program runs fine without it
"maxExports": -1 # so does this
}
# An attacker opens this ONCE on a machine with a real licence, writes the four
# values into the patched binary as constants, and never needs a session again.
# You have made the check slower to find. You have not made it necessary.
# GOOD. Every one of these is a value the program cannot invent.
{
"assetKeyId": 7, # selects which shipped asset pack to decrypt
"assetKey": "b91c…", # the AES key for that pack. No key, no assets.
"tickMs": 8, # the loop rate the physics was tuned against
"curve": [0.0, 0.13, 0.41, 0.78, 1.0], # coefficients, not settings
"solverSeed": "4f2a…" # seeds a table the engine reads every frame
}
# There is no branch to invert. Without the blob the asset pack does not decrypt,
# the solver produces wrong numbers, and the product does not work.
The test to apply: if your program still produces correct output with the blob replaced by an empty object, you have sealed decoration. Put in coefficient tables, timing curves, model or shader constants, the decryption key for an asset pack you ship encrypted, the endpoint of a service that does the valuable work. Not a verdict about the licence.
What _session is for
When you open the blob you get your own fields exactly where you put them, plus reserved keys alongside them. Every reserved key starts with an underscore, so a client that rejects unknown fields has to skip that prefix rather than list the ones that exist today:
{
"assetKeyId": 7,
"curve": [0.0, 0.13, 0.41, 0.78, 1.0],
"_session": {
"secret": "3f9c…", # 64 hex, THIS session, THIS beat
"sessionId": "7f2e…",
"expiresAt": "2026-07-27T12:20:00Z", # end of the current access window
"epoch": 3 # increments when you edit the set
},
"_mark": "a1b2c3d4e5f6…" # 32 hex, opaque, see below
}
The good parameter set above has one weakness left: the plaintext is identical for every customer and never changes on its own, so one buyer can open it once and publish the JSON. _session.secret is the answer, and it is the only part of the blob a published dump cannot supply. It is minted per session, delivered nowhere except inside this blob, and it is re-derived on every heartbeat: the beat sequence is folded into it, so the value that arrives with seq: 2 is not the value that arrived at activate. A refresh resets the sequence, so the first value after a refresh matches the first value after an activation.
Read it from the blob you just opened, every time. A client that caches the secret at activation and keeps using it works exactly once and breaks on its second beat, and the failure lands inside your own engine rather than as a status code you can branch on. There is no opt out and no product setting that turns this off. The secret rotates per beat for every product on every plan, so an engine that caches it has to be fixed in the client rather than accommodated on our side.
Derive real state from it. Seed a table your engine actually reads, key an asset decryption with it, mix it into a value your output depends on. An engine driven by somebody else's dumped parameters then produces wrong output, because the secret in that dump belongs to a beat that has passed. expiresAt lets an offline engine reject a stale dump with no server call. epoch moves when you edit the parameter set, so rotating after a leak tells a running client that what was published is now stale. We can hand you these three values. We cannot make your engine depend on them, and we cannot tell whether it does. That part is yours.
_mark is a 32-character hex value derived from the licence the blob was served against. It identifies nothing to you: it is not the licence id and you cannot resolve it, which is deliberate, because it has to be safe to leave in a debug dump somebody pastes into a support channel. Only the platform can turn one back into a licence, and that is the whole point. If your parameters turn up published on a forum, the copy names the licence it was served to. Do not strip it, do not depend on its length or format, and pass it through untouched if you re-serialise the blob.
Three practical notes. The blob is re-sealed on every heartbeat and on refresh, keyed to the current access token, so re-derive the key each time rather than caching the opened config forever. Salt the HKDF with the device hash in lowercase, because lowercase is what the server salts with: the field is normalised at the edge, so activate and heartbeat seal against the lowercased value from that request and refresh seals against the value stored at activate, which was lowercased the same way. Casing is therefore invisible to the server and never produces a refusal, which is what makes the failure it does cause hard to find. A client that sends uppercase and then derives its key from its own uppercase bytes derives a different key: the blob does not open, on the client, and nothing in any response says why. Emit lowercase, byte identical, and derive from the same bytes.
And if the field is absent, that is a configuration failure, not permission. An unconfigured product emits no engineParams at all, with no error and no signal telling you so. A client that treats an absent blob as "unrestricted" has a bypass built into it.
payloadKey, the protected payload
What it does. Everything else on this page raises the cost of removing your licence check. This changes what removing it achieves. You encrypt something your program genuinely needs, we hold the key and hand it over only inside a live session, and a copy that never activates holds a file it cannot open.
Turn it on. Encrypt an asset bundle, a model, level data, a lookup table, or the constants of a function that matters, with a key you generate. Paste that key into the product page under Protected payload key. We seal it under your account key and never show it again. It arrives in the same blob as your engine parameters, under a reserved field:
{
"yourField": "...",
"_session": { "secret": "...", "expiresAt": "...", "epoch": 3 },
"_mark": "a1b2c3d4e5f6…",
"_payload": {
"version": 4,
"sealed": {
"alg": "ECDH-ES+A256GCM",
"epk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." },
"ct": "base64( iv(12) | tag(16) | ciphertext )"
}
}
}Two shapes, and your client must handle both. When the session carries a usable DPoP key the payload arrives as _payload.sealed and _payload.key is not sent at all. The rest of the blob opens with a key derived from the access token, and an access token sits in process memory and comes out of a dump, which is the wrong bar for the one key your encrypted assets depend on. Sealing it to the device key pair means opening it needs the DPoP private key, which never leaves the machine and, in a TPM or a secure enclave, cannot be extracted. Prefer sealed whenever it is present.
What your client does. If sealed is there: ECDH between your DPoP private key and epk, then HKDF-SHA256(sharedSecret, salt = empty, info = "licentry:payload-ecdh:v1|" + epk.x + "." + epk.y, 32), then AES-256-GCM over ct split as iv(12) | tag(16) | ciphertext. Every primitive is one you already have: the curve is the P-256 you use for DPoP, and the AEAD is the same one that opens the outer blob. Otherwise read _payload.key directly. Either way, the result is the key that decrypts your own files, and the point is that the decryption cannot happen without a session.
"_payload": { "key": "the key you configured", "version": 4 }The plaintext form is reachable only when the session has no usable DPoP key. A client that never sends dpopPublicJwk at activate is the ordinary case; a stored key that will not import is the rare one. Write both branches even if you expect only one, because the shape you get is decided by the session rather than by the product.
Only give it to bound machines. Tick Only release it to a machine-bound session, which is payloadRequiresDpop in the API, and the key goes only to sessions that registered a key from the machine's own keystore, so somebody who copies an installation folder gets a working session and still cannot open your payload. Ship a client that sends dpopPublicJwk at activate before you tick it, or no session will qualify and your own software stops opening its files. That also settles the shape above: a session which qualifies under this setting always has a device key, so the sealed form is the only one it ever receives. A client written against _payload.key alone breaks the day the box is ticked.
Rotate after a leak. Saving a new key moves version up. Re-encrypt your assets with the new key and ship them; a client still holding the old one sees the version change and knows what it has is stale.
If the field is absent, that is not permission. A session that does not qualify gets a blob with no _payload in it, and so does a product that never configured one. The two look identical on purpose, because an error explaining which one it was would tell somebody probing exactly which control to defeat next. A client that treats a missing key as "run anyway" has undone the whole feature.
The honest limit. A paying customer with a live session can read the key out of memory and publish it, exactly as they can with your engine parameters. This moves the work from flipping one branch to extracting a key and redistributing it, which is harder, has to be redone every time you rotate, and tells you from the version number which release the leak came from. It does not make it impossible. Nothing running on a machine somebody else owns does.
vendorResponseSigning, your own signing key
What it does. Signs your product's responses with your account's own key instead of the platform key everyone shares. The signature is what proves an answer came from us rather than from a proxy on the customer's machine, so it is the anchor everything else rests on. With one shared key, a compromise anywhere on the platform can forge a verdict for anyone. With your own, it cannot.
Which key to pin, stated once. While vendorResponseSigning is off, your product's responses are signed with the deployment key from GET /v1/sess/response-sig-jwks and that is the set to pin. Once it is on, they are signed with your account's key from GET /v1/vendor/keys/<your account id>/response-sig-jwks and nothing else, so that is the set to pin. Pin both for the release that spans the change, which is what makes the rollout below safe in either direction.
Turn it on, in this order. The reverse breaks every copy you have shipped.
- Pin your account's keys from
GET /v1/vendor/keys/<your account id>/response-sig-jwks, bykid, alongside the deployment keys your client pins today. - Ship that client and wait for your customers to update.
- Tick the box on the product page, or
PATCH /v1/vendor/products/:idwith{ "vendorResponseSigning": true }. - Drop the deployment keys from your pin set on a later release, once nothing in the field still needs them.
What changes. Every response for that product carries your kid, refusals included, and no platform overlap signature rides along: our rotation key means nothing to a client pinning yours, and offering it would invite verification against a key with no authority over your account.
Pin your build tokens to the product, or one class of refusal escapes this. The key that signs a response is chosen from the build token the caller presented and the product it is registered against, never from the licence, so the kid is the same on an approval, on a refusal, and on an answer for a key that was never issued. That only works when the token names a product. A caller presenting an unpinned token that also omits product from the body still gets the deployment kid when the key does not resolve, which tells whoever sent it that the key is not real. Pass productId when you register a build and the gap closes with no client change.
If your key is ever unavailable, responses arrive unsigned rather than falling back to the platform key. Your client should already treat unsigned as a failure, which is the right outcome: a signature from a key you did not pin is indistinguishable from a forgery, so sending one would be worse than sending none.
minProtocolVersion, the minimum client version
What it does. Refuses to start a session for a client older than you say. Until this existed, every field we added to the protocol was optional forever, because we could not tell a client built before a field from one that chose to skip it. That is why clientNonce was documented for a long time and enforced by nothing.
Turn it on. Have your client send X-Licentry-Protocol: 1 on every call, ship it, then set the minimum to 1. Version 1 means the client sends clientNonce on activate and heartbeat, echoes serverNonce on heartbeat, and sends deviceHash on refresh, which is everything the current samples do.
The version is what the client says about itself. It arrives in a header, it is read at activate and on no other route, and nothing checks that a caller claiming version 1 actually sends any of those three fields: they stay optional in the schema at every version, and each one is enforced by its own rule rather than by the number. So this keeps out an honest old build, and it does not keep out a new one that lies. That is still worth having, because the old builds are the ones that break when you turn a control on, and a vendor deciding what to rely on should know which of the two they bought.
Checked at activation only. Raising the number never cuts a session that is already running, so it is safe to change on a weekday. Anything older is refused with the same generic 400 activation_failed everything else uses, because a distinct error would let somebody sort real licence keys from invented ones.
Response signature verification
What it does. Proves the response came from Licentry and not from a proxy on the customer's own machine. Nothing else in this document is a security control until this one is in place.
Turn it on. Nothing to switch: signing is on server side and every response on /v1/sess/* carries it, successes and errors alike. Fetch the keys once from GET /v1/sess/response-sig-jwks at build time, pin every key it returns by kid, and ship them inside the binary.
What your client does. Verifies before it parses. The canonical message is "${status}|${ts}|${sha256hex(body)}" over the exact bytes received, checked with a two-sided timestamp window, and a response with no signature header is refused rather than skipped. Full order of operations in verifying the server.
The mistake. Fetching the key at runtime instead of pinning it. That moves your entire trust anchor onto the TLS chain, which on an end user's machine is under the end user's control. The runner-up mistake is a helper that returns only the body, so a verified 403 reads as success at the call site. Verify, then branch on the status, then read a field. The third is if (header present) { verify }, which is covered in full because it is the one that never announces itself.
The nonce echo, and the heartbeat chain
What they do. clientNonce binds a response to your request, so a captured signed 200 cannot be replayed at your own client. serverNonce chains heartbeats forward, so a recorded heartbeat sequence cannot be replayed later either.
Turn them on. Nothing to switch. Both fields are accepted on every call today, and both are inside the signed response body.
What your client does. Sends a fresh random clientNonce of at least 8 printable characters on every validate, activate, heartbeat, refresh and logout, and rejects the response if the echo is missing or different, before reading the status. On heartbeat it also sends serverNonce: empty on the first beat, then whatever came back last time.
The mistake. Sending the nonce and never comparing the echo. The signature binds a response to its own body, not to your request, so without the comparison one purchased licence yields one signed 201 that answers every launch of every patched copy. The heartbeat equivalent is dropping serverNonce once you have received one: after the chain has started, omitting the value counts as a mismatch. If you need to retry a beat whose response you never received, resend the same seq with the same nonce you already had; that is recognised as a repeat and advances nothing. Advancing seq while resending an old nonce is not a retry.
How many retries the grace allows, and what the chain looks like when two copies share one token, are covered on the hardening guide in your dashboard. Strict enforcement is a deployment setting, LICENSE_NONCE_STRICT, which is off in the code default and on for Licentry Cloud: there, a mismatch that has spent the retry grace is 401 nonce_mismatch and the beat does not land. Build for the strict behaviour whichever deployment you are on, because a client that is correct under it is correct under both.
Recovery from 401 nonce_mismatch is re-activation, not a refresh. Refreshing looks like the fix and is not: the chain is deliberately carried across a refresh, and the retry path needs a sequence number the first beat after a refresh can never have.
Threat model: what you can actually win
Everything above this line gets you a correct integration. Everything below it is about the person who bought one copy and decided nobody else should have to. If you arrived here from a product page looking for what the product controls do and how to turn them on, that is one section up.
Start from an honest position, because a plan built on a comfortable one will fail. An attacker running your binary on their own machine, with a debugger, a disassembler and unlimited time, will eventually get something to run. No client-side technique on this page or anywhere else changes that. What you can change is how much work it takes, how long the result keeps working before it goes stale, and whether you find out that a key is being shared. Those three are winnable. "Bypass-proof" is not, and any vendor who tells you otherwise is selling you a feeling.
The one mistake that makes the rest of this pointless. if (license.valid) { unlock(); } compiles to a compare and a conditional jump. Flipping that jump, or making the function that produced valid return a constant, takes minutes for someone who has done it before, and it defeats every piece of cryptography on the wire. If your product still runs correctly with the licence call stubbed out to return true, you have bought nothing. The rest of this page is really one idea explained eight ways: make the program need the answer, not just consult it.
Where the attack surface is
Licentry runs the parts an attacker cannot reach: key storage, session state, rate limits, revocation. What ships to the end user is your binary, and that is where a bypass happens. Split the work honestly.
| What the attacker tries | What answers it | Whose job |
|---|---|---|
| Guess or brute-force a key | Per-IP and per-key-prefix rate limits, peppered hashes, identical failure bodies on a padded response schedule | Licentry |
| Read keys out of a stolen database | Keys are stored only as HMACs under your account's own sealed pepper | Licentry |
| Replay a captured "valid" response | Signed responses, heartbeat sequence numbers, the server nonce chain | Licentry issues it, you must check it |
| Point your app at a fake server or a local proxy | Response signature verified against a key pinned in your binary | You |
| Copy an access token to a second machine | DPoP proof of possession, plus the device hash on every heartbeat | You (register the key, send the hash) |
| Copy the whole app-state folder | The dev claim on the offline grace token | You (the check is client side) |
| Patch the binary so the check never runs | Nothing on the wire. Only how your code is built | You alone |
| Share one key with forty people | Device cap, concurrent session cap, sharing evidence on the licence | Licentry surfaces it, you decide |
| Run a patched binary that never calls us | Nothing on the wire, by construction. Engine parameters are the only answer | You alone |
The three goals worth aiming at
- 1
Raise the cost. A bypass that takes an afternoon gets published. One that needs someone to reimplement your engine from a memory dump usually does not, because the person capable of it has better things to do.
- 2
Shorten its life. Revocation versions, short access windows and parameters that are re-sealed on every heartbeat mean a working bypass rots. A bypassed build that stops working after your next parameter change is a support problem for whoever published it, not for you.
- 3
See the sharing. Most revenue loss is not a public bypass, it is one key on six machines. That is detectable from the server side with no client cleverness at all, and it is the cheapest win on this page.
Live native loading
Optional, off by default, and the furthest this platform takes the "make the program need the answer" idea. Instead of shipping part of your program to disk, you upload it to us. A customer who authenticates gets permission to pull it into memory for that launch, and nothing durable is written anywhere we control. Turn it on per product in your dashboard, under Live native loading.
How it runs, end to end. You upload an artifact against a named slot, say core. We encrypt it and store the ciphertext. When a customer's session is live, their client asks for a grant, gets a short-lived single-use token and the decryption key, pulls the encrypted bytes from our edge, decrypts them in memory and hands the buffer to your loader. The grant expires in about two minutes and is burned on first use, so it is worth nothing afterwards. The key is agreed to the device's own key pair when the session is device-bound, which means a stolen access token buys the grant and still not the artifact.
What that gets you. Somebody who copies an install folder gets nothing usable, because the part that matters was never in it. Getting the artifact at all needs a licence that would pass right now, so the person who wants to take it apart has to buy one first. And publishing a new version re-keys it, so anything recovered from an older one is stale against the current server.
Read this before you sell it to anyone. Your loader receives decrypted bytes in memory, on a machine the customer owns. Somebody determined can dump that memory, and one successful dump frees that version permanently: the recovered file needs no licence, no API and nothing from us ever again. "Never touches disk" is a promise your mapping code keeps, not one we can enforce for you, and it says nothing about what is in RAM. This raises the floor and shortens the life of a bypass. It does not make your program unextractable, and you should not tell your own customers that it does.
Two switches sit next to it, both optional. Watermark and trace gives every licence its own copy, stamped with a marker only we can read, so a file found in the wild resolves to one customer. It needs a binary built against the Licentry client, and the dashboard refuses to turn it on for anything that cannot carry the mark rather than letting you believe copies are traceable when they are not. Rotate to expire is the discipline of publishing a fresh version on a schedule, which is what turns a one-off dump into work somebody has to repeat.
The limits worth knowing before you plan around it. An artifact is capped at 48 MB, which is what the watermarking step can process. A licence may fetch 30 times an hour and 200 times a day, counted per launch rather than per download, so a customer relaunching after a crash is fine and a script in a loop is not. Every fetch for one licence and version returns identical bytes, so re-fetching gathers nothing. The full request and response shape is in the client contract.
Getting the session lifecycle right
Hardening a broken integration is wasted effort, so start here. These are the numbers the server actually runs on. The default applies unless a deployment overrides it, and an override is clamped to the range shown.
| Value | Default | Range | Notes |
|---|---|---|---|
| Access window | 20 min | 5 to 120 min | Extended by every accepted heartbeat. Read expiresAt from the response, never hardcode it. |
| Refresh window | 14 days | 1 to 60 days | Reset on every successful refresh. |
| Heartbeat seq gap | 8 | fixed | oldSeq < newSeq ≤ oldSeq + 8. Tolerates dropped beats, not skipping. |
| Devices per licence | 1 | 1 to 100 | Per product, set on the product in your dashboard. |
| Concurrent sessions per licence | 1 | 1 to 20 | Not a rejection. The oldest session is evicted. See below. |
| Offline grace TTL | 72 h on Licentry Cloud | 1 to 168 h | How long a stored offline token stays honourable. A platform setting, not a per-product one. The code default is 24 h; Licentry Cloud provisions 72 h. |
| DPoP proof age | 120 s | fixed | Plus 30 s skew allowance. exp - iat is bounded the same way. |
Heartbeat and refresh are rate limited on the credential rather than on the address, so one machine cannot spend another's allowance, behind an outer per-IP wall that everyone on a shared network does share. Beat every few minutes, not every few seconds, and no honest client ever meets either. The exact ceilings, and the graces on the replay protections, are on the hardening guide: they are the numbers that tell somebody how hard they can push before we notice, so they sit behind a sign-in rather than on a public page.
Activate
Send an Idempotency-Key of at least 8 characters and reuse the same value for every retry of the same activation. It is scoped to the key plus licence plus device hash, so a retry after a dropped response returns the original session with "idempotent": true instead of burning a device slot. Persist everything you get back: both tokens, both expiries, revocationVersion, product, and your own seq counter starting at 0.
Treat the idempotency key as a secret, and generate it fresh for each attempt. Use a cryptographic random source and at least 16 bytes. Do not derive it from the device hash, the licence key or a counter. For the next 24 hours, presenting that key with the same licence and device hash hands back the live tokens the original activation minted, so a predictable key is a second way into the session. Do not log it, and discard it once the activation has succeeded.
Heartbeat
The heartbeat body requires both seq and deviceHash. The device hash is not optional and not decorative: if it differs from the hash the session was activated with, the server revokes that session on the spot, records the mismatch against the licence, and answers 403 license_suspended. That is what makes a copied access token useless on a second machine even without DPoP.
Schedule the next beat from the expiresAt you just received, at roughly a third of the remaining window, with jitter. That gives you two missed beats of slack before the window closes and keeps a fleet from synchronising into a thundering herd.
The nonce pair
Heartbeat carries two optional nonce fields, and using them is close to free. Both are inside the signed response body, which is what makes them worth anything.
clientNonce: a fresh random value you generate per request. The server echoes it back in the signed body. If the echo does not match what you just sent, you are looking at a replayed response, not a live one. This is stronger than a timestamp window because it is bound to this request.serverNonce: the value from the previous heartbeat response. The server chains them forward, so a recorded heartbeat sequence cannot be replayed later: the attacker's copy cannot produce the next nonce the server expects.
Send an empty serverNonce on the first beat of the session, then echo whatever came back. Once the server has issued one, omitting it counts as a mismatch, and that includes the first beat after a refresh: the chain runs for the life of the session rather than the life of the token pair. Licentry Cloud answers a mismatch with 401 nonce_mismatch once the retry grace is spent, so build the chain correctly rather than assuming any particular tolerance. If two of your own heartbeats race each other you get 409 nonce_raced; the fix is to serialise heartbeats, not to retry harder.
To retry a beat whose response you never received, resend the same seq with the same serverNonce you already held. That pair is recognised as a repeat of a beat already processed: the server replies with the nonce you missed and advances nothing, so the call is safe to make. Advancing seq while resending the old nonce is not a retry and will not be treated as one. That grace is bounded, deliberately, because the pattern it allows is also what two machines on one token produce. How strictly the chain is enforced and how much slack the grace has are on the hardening guide.
Refresh and logout
Refresh rotates both tokens and resets the server's seq to 0, so your next heartbeat is seq: 1 again. The old refresh token is remembered: presenting it a second time returns 401 refresh_token_stale and is recorded, because the innocent explanation (a retry after a lost response) and the guilty one (two copies running from the same stolen state) look identical on the wire and only the count tells them apart. Store the new pair before you consider the refresh complete.
Schedule refreshes rather than waiting for an error to force one. Refresh is the only thing that returns the heartbeat retry budget, so a client that refreshes only on failure runs its whole session on a grace it can never replenish. Refresh also returns no offlineGraceJwt and no signingPublicKey, so send a heartbeat after each one before you depend on the offline path.
Call logout on sign-out and on clean shutdown, then clear local state. A session that is never logged out holds its concurrency seat until the refresh window closes, which is days. On the default cap of 1 that means your customer's own next launch evicts their own abandoned session, which works, and writes an eviction that the sharing evidence counts. Skipping the call on a clean exit therefore manufactures a slow drip of sharing signal against customers who have done nothing.
If the session is DPoP-bound, send a proof with the logout too, with ath over the access token. Licentry Cloud provisions LICENSE_LOGOUT_DPOP_STRICT, which is off in the code default, so the call is refused with 401 dpop_required without one and refused the same way with a proof that does not verify. That is the point of it: otherwise anyone holding a leaked access token can end your customer's session, and your client's documented answer to a lost session is to re-activate, which spends device registrations and shows up as eviction churn against the customer it was done to. The other side of it is that a client which cannot build a proof cannot release its own seat, so the proof is not optional if you want logout to do anything.
Verify what the server tells you
An unverified response is not a response from Licentry. It is a response from whoever controls the network path, which on the attacker's own machine is the attacker. A local proxy that answers every activate with a cheerful 201 takes about ten minutes to write. Signature verification is the single check that turns your API calls from decoration into security, and it is the first thing to build. The short version, with the mistake people make, is at response signature verification.
The response signature
Every response body on /v1/sess/* is signed with ECDSA P-256, successes and errors alike, so a 403 is as trustworthy as a 201. Every session route answers with a body, including logout, tamper reports and integrity events, so every response carries both a signature and your echoed clientNonce. Those three routes used to answer 204. They no longer do, because a signed empty body is identical for every route and a captured one could be replayed as the answer to a different call. In production the API refuses to start without a signing key, so a body with no signature header is a failure condition, not a fallback path.
| Header | Contents |
|---|---|
X-Licentry-Sig | Base64 of the raw 64-byte r||s signature. Not DER. This is exactly the form Windows BCrypt verifies. |
X-Licentry-Sig-Ts | Unix seconds at signing time. |
X-Licentry-Sig-Kid | Which pinned key verifies this response. A selector only, and deliberately not part of the signed message, so a flipped kid just fails closed. |
The signed message is "${status}|${ts}|${sha256hex(body)}", hashed with SHA-256 and signed. The body hash is over the exact bytes you received. Parse the JSON after the check, never before, and never re-serialise the body to hash it.
An absent signature is a refusal. Say that as an imperative with no branch in it.
There are real paths on which we send no signature header at all. A product with vendorResponseSigning on whose account key cannot be loaded is answered unsigned, deliberately, because a signature from a key you did not pin is indistinguishable from a forgery and sending one would be worse than sending none. So "no header" is a state your client will genuinely meet, and it is also the state an attacker produces by stripping one header on the way past.
A verify routine shaped like if (response.has(SIG)) { check it } is one line of plausible defensive code and it is in a large share of first-draft integrations. It fails open on every stripped header, on every response, silently, for the life of the product. No signature, no licensed path.
The minimum correct verification, in this order. Nothing here is optional and nothing may be reordered.
raw = response.body_bytes # keep the bytes, not the parsed object
sig = response.header("X-Licentry-Sig")
ts = response.header("X-Licentry-Sig-Ts")
kid = response.header("X-Licentry-Sig-Kid")
# 1. present, and the right shape
if sig is empty:
fail_closed() # absent is a refusal, never a skip
raw_sig = base64_decode(sig)
if len(raw_sig) != 64:
fail_closed() # raw r||s, exactly 64 bytes, NOT DER
# 2. freshness, two sided
if abs(trusted_now_seconds() - int(ts)) > 300:
fail_closed() # one sided is defeated by moving the clock back
# 3. the kid selects the key. it is NOT part of the signed material
if kid not in PINNED_KEYS:
fail_closed() # a kid you do not pin is a failure, not a skip
# 4 and 5. build the canonical string, verify over THAT
canonical = str(response.status) + "|" + ts + "|" + sha256_hex(raw)
if not ecdsa_p256_sha256_verify(PINNED_KEYS[kid], canonical, raw_sig):
fail_closed()
# 6. the echo, before the status
body = json_parse(raw)
if body.clientNonce != the_nonce_you_just_sent:
fail_closed() # a valid signature on somebody else's answer
# 7. only now branch on response.status, and only now read a field
Two things in there catch people. The signature covers the canonical string and not the body, so a client that verifies over the body bytes fails every response and usually concludes the key is wrong. And the status is inside the signed material, which is why a 200 and a 403 with identical bodies have different signatures and a success cannot be replayed over a refusal.
"Fail closed" means the licensed path does not run. It does not mean falling back to a cached verdict, and it does not mean retrying until something parses. A verification failure is either a broken deployment or an active attack, and both deserve the same answer.
Why pinning beats fetching
You can read the public key from GET /v1/sess/response-sig-jwks, and that endpoint exists so you can regenerate your pin set after a rotation. Do not fetch it at runtime and trust the result. Fetching moves your entire trust anchor onto the TLS chain, and the TLS chain on the end user's machine is under the end user's control: installing a root certificate and running a proxy is a documented, supported operation on every desktop OS. A key compiled into your binary has to be found and replaced in the binary, which puts the attacker back where you want them.
Surviving a rotation
Rotation works from the client side, and the whole of it is one rule: pin every key the JWKS returns, as a map, not just the first. During an overlap the server holds two keys and signs one shared timestamp and body hash with both: the primary in X-Licentry-Sig and X-Licentry-Sig-Kid, the incoming one in X-Licentry-Sig-Next and X-Licentry-Sig-Kid-Next. Ship a build that pins both kid values, wait for adoption, then promote on the server.
The selection rule: build the candidate list as (Kid-Next, Sig-Next) first when both are present, then (Kid, Sig); take the first candidate whose kid you pin; verify that one. If it fails, the response is bad, so stop. Do not fall through to the other candidate hoping it passes, and treat "I pin neither kid" as a failure rather than a skip. Preferring the -Next pair is what makes a build survive promotion day: you were already verifying that key, and promotion just moves the same kid into the plain header.
Two constraints, and a client that requires either pair is broken.
The overlap signature is best effort and can be absent under load. The second signature is computed after the primary, and a failure there is logged and swallowed rather than failing the response: losing it costs new clients their preferred verifier, while failing the whole response would cost every client theirs. So -Next can be missing on one response and present on the next for reasons that have nothing to do with you. Requiring it is a bug. Fall back to the primary pair, which is what the candidate walk above already does.
A vendorResponseSigning product gets no overlap pair at all, by design, so the walk finds exactly one candidate and verifies it.
Activate and heartbeat responses also carry a signingPublicKey object. That is the offline grace verification key, not the response-signing key, and it rides along so a client can follow that rotation without re-activating. Treat it as a hint you accept only after the response signature has already verified, never as a trust root. A self-describing key inside a response you have not authenticated proves nothing. Refresh does not carry it, so a client that only refreshes across a long idle period cannot follow an offline grace rotation at all: beat at least once after each refresh.
The offline grace token
The same rule governs it: pin the key, by kid, at build time, and never fetch it at runtime. Which set to pin is not obvious and pinning the wrong one fails every token you will ever receive, so the three steps are written out in full at offline grace. Skipping the dev claim check is the one mistake there that turns the token into a bearer entitlement: copying the state folder to another machine keeps working for the full TTL, and since a fresh token arrives on every heartbeat, redistributing them sustains it indefinitely.
Clock skew
Every time-based check on both sides has a tolerance, and the client's wall clock belongs to the user. The server allows about 30 seconds of skew on DPoP proofs; outside that, proofs are rejected for timing reasons and the fix is to resync and build a fresh proof, never to resend the old one. On your side, keep a monotonic reference alongside wall-clock time. If the wall clock jumps backwards between two heartbeats, that is either a timezone update or someone extending an offline window by hand, and stored grace tokens should be treated as suspect until a real heartbeat succeeds.
Device binding, caps and DPoP
What a good device hash is made of
The API validates one thing about your device hash: that it is 64 hex characters. Everything else is your recipe, and a recipe that moves is accepted silently on the run that computes it and then fails on your customer's machine instead of yours. When a heartbeat or a refresh arrives with a hash that does not match the one the session was activated with, the session is revoked on the spot, the client gets 403 license_suspended, and the revoke is recorded against the licence as a hardware mismatch. At activate, a changed hash registers as a second device, so at the default cap of 1 it is refused with 403 too_many_devices until an HWID reset. Nothing in any of those answers names the recipe as the cause.
Every ingredient must be readable, without elevation, on every run, or it is not an ingredient. Not "read it when we can", not "prefer it when available", not "empty on failure". A disk hardware serial on Windows needs an open handle to \\.\PhysicalDriveN, which needs administrator. /sys/class/dmi/id/board_serial and product_uuid on Linux are mode 0400, root only. Read one of those and substitute an empty value when the read fails, and your client computes one hash elevated and a different one not elevated, which is a paying customer cut off mid-session and evidence in your dashboard that they were sharing their key.
ingredients = [ i1, i2, i3, i4, i5 ] # fixed count, fixed order, per platform
each ingredient, before joining:
read as bytes, truncate at 256
strip leading and trailing 0x00 0x09 0x0a 0x0d 0x20
any remaining byte outside 0x21 to 0x7e collapses the WHOLE ingredient to ""
lowercase, ASCII only
a failed read yields "", and the slot is never dropped
canonical = "licentry-dh-v1" + US + salt + US + join(ingredients, US) # US = 0x1f
device = sha256_hex(canonical).lower() # 64 lowercase hex
The fixed count is what makes the rest work. A missing ingredient becomes the empty string and keeps its slot, so a machine where one read fails hashes the same as the same machine on a run where it succeeds. 0x1f as the joiner cannot appear inside a printable ASCII ingredient, so no ingredient can be made to look like two. Collapsing a non-ASCII ingredient to empty is there because OEM serials sometimes carry a trademark symbol whose encoding varies by locale and by which API you called, and a deterministic collapse is stable where a transcode is not. The licentry-dh-v1 prefix makes a later recipe change a versioned migration you can see in your own source rather than a silent re-binding of everyone you have already sold to. The salt is a constant compiled into your binary: without it, two products using Licentry on one machine compute the same hash and correlate that machine across two unrelated vendors.
| Slot | Windows | macOS | Linux |
|---|---|---|---|
| i1 | MachineGuid under HKLM\SOFTWARE\Microsoft\Cryptography, opened with KEY_WOW64_64KEY | IOPlatformUUID from IOPlatformExpertDevice | /etc/machine-id |
| i2 | SMBIOS Type 1 system UUID via GetSystemFirmwareTable | IOPlatformSerialNumber, same node | /sys/class/dmi/id/product_name |
| i3 | SMBIOS Type 2 baseboard serial, same call | hw.model via sysctlbyname | /sys/class/dmi/id/sys_vendor |
| i4 | GetVolumeInformationW serial for the volume holding the Windows directory | machdep.cpu.brand_string, absent on Apple silicon | /sys/class/dmi/id/product_family |
| i5 | ProcessorNameString under HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0 | reserved, always empty | model name of processor : 0 in /proc/cpuinfo |
None of those needs elevation on any of the three platforms. Two of them have a detail that decides whether your implementation works. KEY_WOW64_64KEY on Windows i1 is not optional: a 32-bit process on a 64-bit OS without it lands in the WOW6432Node redirect and reads nothing, so shipping an x86 and an x64 build of one product gives you two hashes for one machine. And Linux i1 falls back to /var/lib/dbus/machine-id only when /etc/machine-id does not exist, never when it exists and the read fails. "The file is not there" is a stable fact about the platform. "The read failed" is transient, and branching on it is what moves the hash.
Use GetSystemFirmwareTable rather than WMI for i2 and i3. Win32_ComputerSystemProduct.UUID returns the same value, but it needs the WMI service, costs 100 ms or more, initialises COM, and returns nothing against a corrupt repository.
| Refuse these | Why |
|---|---|
| Any disk hardware serial, on any platform | Windows needs administrator for it and Linux needs root. Elevated and normal runs disagree, and one of them is the run your customer does every day. |
board_serial, product_serial, chassis_serial, product_uuid, dmidecode | Root only on Linux. Same failure. |
| Any MAC address, on any platform | A dock presents a new adapter and can become the primary interface. On several Mac models a Thunderbolt dock's Ethernet becomes en0, so it is not only the value that moves, it is which interface is primary. Spoofable as well, and every popular spoofer changes it first. |
| Hostname, username, domain | Changed from a settings dialog in five seconds, and macOS renames the host by itself on some DHCP networks. |
Device instance IDs, PnP IDs, Win32_NetworkAdapter | Change on a driver update. |
| GPU identifiers | A laptop on battery disables the discrete GPU, so the hash depends on where the machine is plugged in. |
| Display EDID | Changes when the lid closes on a dock. |
/proc/sys/kernel/random/boot_id | Changes on every boot. It sits next to machine-id in every search result and looks exactly like it, which makes it the most expensive wrong answer on Linux. |
IORegistry boot-uuid, boot-args, /proc/self/cgroup, the root filesystem UUID | Per boot, per container, or moving under LVM and overlayfs. |
| A random UUID your app wrote to a file on first run | Copy the file, copy the machine identity. This turns your device cap into a licence for anyone with the folder. |
| Environment variables, or anything the user can type | If a text editor changes it, it is not hardware. |
| TPM endorsement key, machine SID, OS serial number | Elevation on some Windows SKUs, and each changes on a TPM clear, a sysprep or a reactivation. |
Virtual machines and containers are not special cases, and adding a branch for them is where non-determinism gets in. Many hypervisors write an all-zero or all-ff firmware UUID and an Apple silicon VM can report an empty serial: take the value as read, because a shared constant contributes no entropy and costs nothing while a branch costs you determinism. Containers still move the hash, but that is a deployment problem rather than a recipe problem. A Docker or Podman container gets a fresh /etc/machine-id per container unless it is bind-mounted, and a Windows container gets a fresh MachineGuid per start, so the hash moves on every launch and the identity has to come from the deployment instead. WSL2 has its own machine-id unrelated to the Windows host, so a product shipping a Windows build and a Linux build sees two devices for one physical machine at a default cap of 1.
Prove it before you ship, and the proof is four comparisons. Compute the hash, reboot, compute it again, compare: no test inside a single boot replaces that one, and it is the failure that reaches your customers. Then compute docked and undocked, compute on mains and on battery, and compute once elevated and once as an ordinary user in the same boot. Any difference means an ingredient has to come out. A self test that computes the hash 64 times at startup and refuses to run if any two results differ costs about 3 ms and turns a non-deterministic recipe into a failure at your desk.
Emit the hash in lowercase, byte identical on every call. The API lowercases it at the edge, so casing costs you nothing server side, but the sealed engine parameters are salted with the device hash and your client derives that key from its own copy of the bytes. Send one casing and derive with another and the blob will not open, with no error explaining why.
Keep the tooling after you ship: a command that prints the composed hash and the sha256 of each individual ingredient, never the raw values. When a customer reports a lockout, that tells you which ingredient moved between two runs without a board serial landing in a support ticket.
Changing a recipe you have already shipped
A recipe change moves every customer's hash on the same day, so it is not a gradual migration. At the default device cap of 1, each licence already has its one slot filled by the old hash, and the next activation presents a hash the licence has never seen: 403 too_many_devices, for your whole installed base, each one needing a slot freed before that customer can run again. Make room before you ship rather than clearing slots afterwards.
- Raise the device cap to 2 on the product first. It takes an account owner or an admin seat signed in to the dashboard, because the device cap is one of the runtime protections an API key is refused with
403 policy_change_forbidden. That makes it a step you cannot script, and it has to land before the new build reaches anyone. - Ship the new recipe, with a new version prefix. Each customer's next activation binds the new hash into the second slot instead of being refused. Nobody opens a ticket, and the old hash sits in the first slot doing nothing.
- Clear the stale rows once your own telemetry says the fleet has moved, then put the cap back to 1.
GET /v1/vendor/licenses/:id/deviceslists both rows with their hashes, andDELETE /v1/vendor/licenses/:id/devices/:deviceIdunbinds the old one.POST /v1/vendor/licenses/:id/hwid-resetclears both, which is also fine: a client rebinds its current hash on its next activation, reclaiming the slot rather than consuming a new one.
You can reset in bulk. POST /v1/vendor/licenses/bulk takes {"ids": [ … ], "action": "hwid-reset"}, up to 500 licences per call, and runs the single-licence reset once per id, which is where its limits come from. Every reset is metered by a per-licence cooldown, one hour by default, so a licence you already reset that hour comes back as a failure rather than a reset. The response carries counts only, applied, failed and outOfScope, and not which ids failed, so keep your own list and re-run the batch instead of expecting the response to tell you what is left. And the bulk route takes the licenses.manage scope rather than the devices.reset scope the single-licence reset takes, so a support key scoped to resets alone cannot call it.
Order matters, and the wrong order undoes itself. A reset clears the device rows and bumps the revocation version, so every live client re-activates at its next call. A client that re-activates on the old build presents its old hash, the cleared row for that hash is reclaimed rather than a new slot being consumed, and the slot you just freed is full again. Reset after a customer is on the new build, never before.
Each of these paths interrupts a customer once. A full reset revokes the live session outright, so the next call answers 401 invalid_session; a single unbind leaves the session alone but still bumps the revocation version, so the next call answers 401 stale_revocation. Either way the client re-activates and carries on. One interruption per customer is what you are buying instead of a lockout and a support queue.
Two different caps, two different behaviours
They are easy to confuse and they fail in opposite ways. Both are set on the product, and the concurrent one is covered in full at switch the protections on.
- The device cap is per product and defaults to 1. Activating from a machine beyond the cap is refused with
403 too_many_devices, and anactivation.blockedwebhook fires. Devices cleared by an HWID reset stop counting, and re-activating on the same hardware afterwards reclaims its old slot rather than consuming a new one. - The concurrent session cap defaults to 1 and does not refuse anything. A new activation evicts the oldest session by last heartbeat. The evicted device sees
401 invalid_sessionon its next beat and re-activates, which evicts the other one.
That second behaviour is deliberate. Two people sharing a key do not get a clean error, they get an application that keeps quietly interrupting itself. Your customer feels the sharing before you have to tell them about it.
What sharing looks like from the server
GET /v1/vendor/licenses/:id/insights returns named indicators, per-indicator severity, raw counts and a plain-language verdict of none, possible or strong. Nothing there is automatic enforcement: no row is written and no session is cut. It is evidence for a human, because auto-banning on a heuristic costs you honest customers faster than sharing does. Read the timeline, then use suspend, revoke or HWID reset. The same view backs the licence console in your dashboard.
Which indicators exist, what each one counts, and the level each fires at are on the hardening guide behind your dashboard sign-in. That detail is the one thing on this page an attacker cannot work out by watching their own copy of your client, and knowing it is what lets somebody share a key while staying one under every threshold.
DPoP: what it buys
Without it, your access token is a bearer credential. Anyone who reads it out of memory, a log line or an unprotected file can heartbeat as your customer from anywhere. With it, the token is useless without a private key that never leaves the machine, so token theft alone buys nothing. Requiring it is one switch, covered at requireDpop.
Generate a P-256 keypair in the strongest store the platform offers (TPM-backed CNG, Keychain, Keystore, libsecret) and register the public JWK at activate as a JSON string. A malformed or unimportable key is rejected with 403 dpop_jwk_invalid rather than quietly downgrading you to an unbound session, so binding cannot fail open on a serialization bug in your client. Once a session is bound, heartbeat and refresh require a proof; there is no way to unbind from the client.
Per request, sign a fresh ES256 JWT: htm is POST, htu is the public origin plus the path with no query string, which is the origin you were given rather than the host your process connected to, ath is base64url(SHA-256(token)) over the access token for heartbeat and logout and over the refresh token for refresh, jti is a unique string of at least 8 characters, and iat and exp are both required with a lifetime inside 120 seconds. The server remembers each jti for 150 seconds and rejects a repeat, so never reuse a proof on a retry. Build a new one.
Proofs are single use inside the replay window, so a retry always means building a new one. There is one honest limitation in how that memory is held, and it is written up on the hardening guide. It changes nothing about how you build the client, and it is our problem rather than yours on every plan.
Tamper resistance in your client
This is the part nobody can do for you. The server can prove a licence is real; only your code decides whether that proof matters to anything. Everything here is a cost multiplier rather than a wall, and the ones that matter most are also the cheapest: verify the signature, check the nonce echo, and make something the program needs come out of the session.
1. Never gate on a boolean
Any single value that means "licensed" is a single value to patch, and it does not matter whether it lives in a register, a field or a global. The goal is not a check that is hard to find. It is a program that cannot produce correct output without the real session, so that finding the check does not help.
# WRONG. One conditional jump stands between the attacker and your product.
if (session.valid) {
unlockFeatures();
}
# ALSO WRONG. A single variable set once and read everywhere is one byte to patch,
# or one memory write to freeze.
g_licensed = session.valid;
# WRONG IN A SUBTLER WAY. The verdict is centralised, so there is exactly
# one function to make return true.
bool IsLicensed() { return g_session != null && g_session.valid; }
# RIGHT. The data the program needs only exists if the session was real.
params = openEngineParams(session.accessToken, deviceHash) # AEAD, fails closed
engine.init(params.tickMs, params.curve, params.tables) # no params, no engine
2. Make the licence materially necessary
This is the whole game, and Licentry ships a mechanism for exactly it. When engine parameters are configured for your product, activate, heartbeat and refresh all carry an engineParams field: an AES-256-GCM blob whose key is derived from the access token of a live session. Setting it up, the HKDF recipe, and a worked example of a bad parameter set against a good one are one section up. What follows is why it is the one technique on this page that holds.
Follow what it means for an attacker. The key material is the access token, which only comes from a real activation against the real server. Someone who NOPs your licence check never obtains a token, so never derives the key, so never sees the plaintext. There is no branch to invert, because there is no branch: there is a decryption that either produces your parameters or produces nothing. GCM authenticates, so a modified blob fails rather than yielding garbage that might limp along. And the blob rides inside the signature-covered response body, so it cannot be swapped in transit.
The token rotates on refresh, so the blob is re-sealed on refresh and on every heartbeat. Re-derive the key each time rather than caching the opened config forever. That is what makes your engine depend on a live session instead of a one-off boot check, and it lets the parameter set change under a running fleet.
What you put in there decides whether this is real protection or theatre. Put in values the product genuinely cannot work without and cannot plausibly be guessed: coefficient tables, timing curves, model or shader constants, the decryption key for an asset pack you ship encrypted, endpoints for a service that does the valuable work. Do not put in {"licensed": true}. A sealed boolean is still a boolean, you have just made it slower to read.
Choose parameters an attacker cannot recover by watching your program run once with a valid licence. If everything in the blob can be lifted from a single memory dump on a legitimately activated machine, you have raised the cost of the first bypass and nothing after it. That is still worth something. It is not the same as needing a live session, and _session.secret is what closes the difference.
3. Spread checks across time and code
One check at startup is one place to look. It is also useless against the attacker's actual workflow, which is to get past the launch screen once and then never see the check again. Verify at intervals, and verify at moments that matter: when a document is saved, when an export starts, when the expensive feature is invoked. Use the heartbeat you are already sending as the clock. A bypass that survives launch but corrupts an export an hour later generates complaints in the support channel of whoever published it, not in yours.
4. Do not centralise the verdict
Resist the tidy LicenseManager.IsValid() that everything calls. It is a single symbol, a single return value, and a single point of failure that static analysis will find in seconds. Prefer several independent paths that each need something derived from the session, so that no single edit turns the product on. This trades some cleanliness for real resistance, which is a trade worth making only in the parts of your code that actually protect revenue. It is not a reason to make an entire codebase unpleasant.
5. Tamper and debugger detection, honestly
Integrity self-checks and debugger detection are worth having and are not worth trusting. A determined attacker patches the detector, and the detector is easier to find than the thing it protects. Their real value is raising cost for casual attempts and giving you telemetry. Two rules keep them useful. Never let a detection result collapse into one boolean that gates everything, or you have rebuilt the original mistake with extra steps. And prefer degrading strangely over exiting loudly: a process that calls exit() on detection tells the attacker precisely which check to remove and precisely when it ran.
Two authenticated endpoints exist for reporting. Both need Authorization: Bearer <accessToken> and an active session, and both answer 200 with { "ok": true } and your echoed clientNonce.
Body { "eventType": "...", "reason": "...", "meta": { } }. Recognised types are code_integrity_fail, anchor_seal_fail, snapshot_rollback, module_injection_detected and debugger_detected; anything else is recorded as unknown. Events land in the server-side audit log.
Body { "reason": "...", "screenshot": "..." }, both optional, for a richer report at the moment of detection.
Reporting is one-way today: there is no vendor endpoint to read these back, so treat them as operator telemetry rather than a queue you consume. Their design value is that they move the decision off the client. A client that reports and keeps going is harder to reverse than a client that judges and quits.
6. Obfuscation raises cost and prevents nothing
Everything an obfuscator does is reversible, because the CPU has to be able to run the result. What obfuscation buys is time, and time is a real currency here. Spend it where it pays: on the code that derives and uses engine parameters, on the signature verification path, on the device hash recipe. Do not spend it on your whole application. Whole-program obfuscation costs you performance, crash reports you cannot read, false positives from antivirus vendors, and debuggability, in exchange for slowing an attacker down at points where they were not going to look anyway. Packers in particular are usually a poor trade: unpacking is automated, and the packer itself is often what gets your binary flagged.
7. The server is authoritative, the client is advisory
Anything a client decides can be un-decided by whoever owns the client. If a capability is valuable enough to protect and can plausibly run server-side, run it server-side, gate it on the session there, and let the client be a viewer. Nobody has ever bypassed a feature that was never in the binary. This is not always practical, offline tools exist, but it is the only technique on this page that actually holds against a determined attacker, so reach for it before the clever ones.
If you run your own backend, never let it act on a client's word that the client is licensed. Check licence and live session state yourself through the Vendor API before you serve anything that costs you money, using a server-side API key that never ships inside a client.
8. Know your offline window, because you do not set it
The offline grace TTL is a platform setting rather than a product one. There is no field for it on the product and none in PATCH /v1/vendor/products/:id: one value applies to every licence on the deployment. Licentry Cloud provisions 72 hours, which is chosen to survive a weekend outage on a single machine rather than to be a security parameter.
The trade is real and it is made for you. A long window is kind to a customer on a plane and is exactly the window in which a revoked key keeps working with our hostname blocked in a hosts file, which is the first thing an attacker tries. Design around the number: it is the worst-case delay on your own kill switch, so anything you need to stop faster than that has to be stopped by something other than revocation, and the dev claim check is what keeps the grace token from being copied to a second machine inside it. If your product genuinely needs a different window, that is a conversation with support and not a switch.
9. Do not build an oracle
The server answers every failed activation identically and on a padded schedule, so activate cannot be used to sort a stolen key list into live and dead. Do not undo that in your UI. "We could not activate this key" is the right message. "This key expired on the 4th" tells someone with a list of stolen keys exactly which ones are worth trying. The same applies to your logs and your support macros.
Where this ends. Someone with a debugger, your binary and enough motivation will get a build running. Every technique here is a cost multiplier, not a wall, and anyone who promises you a wall is either wrong or selling. Aim at the realistic outcome: a licence bypass is expensive, it rots as parameters rotate and revocation lands, and casual key sharing shows up in your dashboard within a day. That combination protects revenue. Chasing perfection past that point spends engineering time you could spend on the product, which is the trade the attacker is actually hoping you make.
Which of these attacks we can see from the server, which leave nothing at all, and what defeats each control are written up honestly on the hardening guide in your dashboard. Read it before you decide where your effort goes: roughly half of what people spend time on defends against something that never reaches our network in the first place.
Failure handling
Half of a hardened client is the error paths. A retry loop in the wrong place turns a revoked licence into a working one, and a stop in the wrong place turns a flaky hotel network into a support ticket. Handle each of these deliberately.
| Response | What happened | What your client does |
|---|---|---|
403 session_killed | An operator terminated this session on purpose, from the dashboard, the API or the bot. Carries a reason. | Stop. Do not retry, do not re-activate. Drop to the unlicensed state or exit. Log the reason, show the user something generic. |
401, no error field | A bare string body. The Authorization: Bearer header was missing or blank, so the request was refused before any lookup and there is no code to read. | Your bug. Fix the header. Re-activating masks it, and will keep masking it. |
401 invalid_session | No session for that token, or the session was revoked for an ordinary reason, or the seat was lost to the concurrency cap because an activation elsewhere took it. | Re-activate once, with backoff. Never in a tight loop, that is how a shared key turns into a rate-limit ban. |
401 stale_revocation | The licence changed underneath the session: revoke, freeze, or an HWID reset bumped the version. | Stop the licensed path immediately, then re-activate and honour whatever comes back. Do not coast on cached state. |
401 expired, session window | The session's own access or refresh window lapsed. | Refresh. Re-activate only if the refresh is also refused. |
401 expired, licence validUntil | The licence itself ran out. Same wire code, different meaning, and the body does not tell them apart. | Terminal. Refresh fails too, and activate answers 400 activation_failed. Try the refresh once; when that also refuses, stop and point the user at their seller instead of re-activating in a loop. |
401 revoked | The licence was revoked. Arrives on heartbeat and refresh. | Terminal. Re-activation cannot succeed. Stop and show a generic message. |
401 nonce_mismatch | The heartbeat nonce chain broke and the retry grace is spent. Strict deployments, which includes Licentry Cloud. | Re-activate. Refreshing does not fix it: the chain is deliberately carried across a refresh, and the retry path needs a seq that the first beat after a refresh can never have. |
401 refresh_token_stale | A rotated refresh token was presented again. Either you retried a refresh whose response you lost, or a second copy is running from the same state. | Use your newest stored pair. If that fails, re-activate. Do not loop: the two explanations look identical to us and only the count separates them. |
403 license_suspended | Frozen, flagged, or the session was cut because the device hash did not match the one it was activated with. | Stop. Point the user at support. Re-activating in a loop will not help and looks like abuse. |
403 too_many_devices | Every device slot on the licence is in use. | Stop and explain that the key is on another machine. Your support path is an HWID reset. |
400 activation_failed | Unknown, expired, revoked or stale key. Deliberately indistinguishable. | One generic message. Do not branch on it, do not auto-retry, do not guess in the UI. |
426 upgrade_required | The build token is missing, unrecognised, retired, or superseded by a newer pristine build. | Prompt the user to update. Retrying with the same binary cannot succeed. |
400 device_required | A refresh arrived with no deviceHash, on a deployment that requires it or for a DPoP-bound session. A 400 rather than a 401: nothing was revoked and the session is still there. | Add the field and refresh again. Send the same hash you sent at activate, lowercase and byte-identical. Retrying the same body cannot succeed. |
409 stale_seq | You sent a sequence number the server already has. | Set the next value to stored + 1 and continue. Not fatal. |
409 seq_gap | You jumped more than 8 ahead. | Refresh, then restart at seq: 1. |
409 nonce_raced | Two of your heartbeats were in flight at once. | Serialise heartbeats. Retry after the in-flight one resolves. |
401 dpop_required and every 401 dpop_* | Proof missing or rejected: timing, wrong URL, wrong token hashed, or a reused jti. | Build a fresh proof and repeat the same beat: same seq, same serverNonce. Do not re-activate, and never resend a proof. On timing errors resync the clock first. |
400 invalid_jwk_json, jwk_not_p256, jwk_import_failed | The DPoP key stored for this session is unusable. A 400 rather than a 401, because these three codes are not prefixed dpop_ and fall past the route's DPoP branch. | Permanent for this session, and no retry ever clears it. Generate a fresh key pair and activate again. A client that retries every non-401/403/409 after thirty seconds loops here until the refresh window closes. |
429 | Rate limited. | Honour the RateLimit-* headers and back off with jitter. |
503 | The API or its database is not ready. | Back off and retry. Fall through to your offline policy, not to an unlicensed-but-running state. |
Do not re-activate on every 401. It is the shortest error handler and it is wrong for three of the rows above and expensive on a fourth. Re-activating on dpop_* throws away a healthy session over thirty seconds of clock skew. Re-activating on expired before trying a refresh spends a device registration and an activation rate-limit slot to obtain something the refresh would have handed over. And on the terminal rows it fails, repeatedly, which looks from our side exactly like an attacker replaying a captured session. Read error and branch.
session_killed and invalid_session are not variations of each other. The first means a human deliberately pulled the plug on this session and re-activating is fighting them. The second means the session record is simply not usable any more, most often because the customer started the app on another machine, and re-activating is the correct and expected response. Get these two backwards and you either fight your own operators or lock out a customer who just moved desks.
The retry budget, and the loop that never replenishes it
If a heartbeat response never arrives, the retry is the identical body: same seq, same serverNonce. The server recognises the pair as a repeat of a beat it already processed, replies with the nonce you missed, and advances nothing.
That grace is bounded, and only a refresh returns it. A successful beat does not, deliberately. So a client that refreshes on a timer replenishes the budget as a side effect, and a client that refreshes only when something forces it, on a 409 or on an expired token, never does. A handful of lost responses spread across a long session exhausts the grace, and the next lost response is a hard 401 nonce_mismatch and a forced re-activation. Re-activation needs the licence key, which means either your secure storage or your customer typing it in again, so an event that began as a dropped packet ends as a dialog in front of a paying user. Refresh on schedule, not on error. The exact budget is on the hardening guide.
When the network is gone
A failed request is not a licensing decision. Do not read a timeout as valid, and do not read it as revoked. Both are wrong, and the second is worse, because it punishes exactly the honest customer who is on a bad connection.
- Distinguish transport failures (DNS, connect, timeout, TLS) from authenticated verdicts. Only a verified, signed response is a verdict.
- Fall through to the offline grace token if you have a valid one, with
dev,exp,rvandprodall enforced. If you do not, degrade the way you would want to be treated on a plane, and be clear in the UI about what is happening and when it runs out. - Retry with exponential backoff and jitter. Do not hot-loop a dead endpoint: you will hit the rate limiter and turn a temporary outage into a longer one.
- The moment connectivity returns, heartbeat. That is what re-syncs the revocation version, delivers a fresh grace token and closes the offline window early.
Account security
An attacker who cannot get past your client may find it easier to take your account instead. This is the cheaper attack and it is defended with configuration rather than engineering.
API keys are a server-side credential
A vendor API key (lk_live_ plus 40 characters) can issue, revoke, suspend and extend licences and reset devices, according to its scopes. It belongs on your servers. Not in a desktop binary, not in a mobile app, not in a web page, not in a config file you ship, not in a public repository. Your distributed client uses the session endpoints and its build token, and never needs an API key for anything. Only a hash is stored, so we cannot show you a key again after it is created, and a database leak does not expose usable keys.
- Scope every key to the minimum. A key that resets HWIDs for your support tool does not need
licenses.issue. Reserve*for something that genuinely needs everything. - Bind keys to a product when the integration only serves one. A product-bound key that touches another product's licence gets
404, not403, so it cannot even probe for what else exists. One key per app means one leak never exposes the rest. - Rotate by overlap. Create the new key, deploy it, then delete the old one. Deletion takes effect immediately, so deleting first means downtime.
- Minting is dashboard-only. No API key can create another API key, register a webhook endpoint, change a webhook URL or rotate a signing key. A leaked key therefore cannot escalate itself or redirect your event stream.
- If a key leaks: delete it first, then review what happened in the window: licences issued, licences revoked, HWID resets, sessions killed.
Webhook receivers
Your webhook endpoint is a public URL that grants entitlements. Treat it like one.
- Verify
Licentry-Signaturebefore anything else: HMAC-SHA256 overt + "." + raw bodywith that endpoint's secret, compared in constant time. Sign the bytes exactly as they arrived, because re-serialising the JSON changes them. - Reject a timestamp more than 5 minutes from your clock. The timestamp is inside the signed material, so a captured delivery cannot be replayed later under a fresh header, but only if you actually check it.
- Deduplicate on
Licentry-Delivery. Retries reuse the delivery id, so a receiver that acknowledged late will see the same event twice. Make every handler idempotent. - Answer 2xx as soon as the signature checks out, then do the work in a queue. The request is abandoned after 10 seconds and counted as a failure.
- Events can arrive out of order. Use them as triggers, and treat the Vendor API as the source of truth for current state.
Team seats and least privilege
Members are restricted by path in one place rather than by a check bolted onto each route, so a route added later is denied to restricted roles by default instead of being quietly open. The account owner is never a member and is never restricted.
| Role | Can | Cannot |
|---|---|---|
| Owner | Everything, including billing and the team. | Nothing. Give this to one person and put 2FA on it. |
| Admin | Day to day: products, keys, devices, API keys, webhooks, Discord. | Billing, team management, the account password and 2FA. |
| Developer | Issue and manage keys, manage products, read everything. | Mint API keys, rotate signing keys, change the integration setup. |
| Viewer | Read only. The right seat for support staff. | Change anything at all. |
What we store, and what only you can protect
| Item | Licentry holds | You hold |
|---|---|---|
| Licence keys | An HMAC under your account's own sealed pepper. Never plaintext, so nobody can read a key back out of the database. | The plaintext keys returned once at issue. If you neither store nor deliver them, they are gone. |
| Runtime tokens | Hashes only, so a database dump does not yield usable sessions. | The live tokens on the customer's machine. Platform secure storage, never a plaintext file. |
| Signing keys | Your account's private keys, sealed at rest. Public halves served from your own JWKS URLs. | The public keys and their kid values pinned inside your shipped binaries. |
| Session history | Device hashes, session and device timelines, IPs and user agents from licence traffic. This is what the sharing evidence reads. | The device hash recipe. Salt it per product so the value you send is not a global machine identifier that correlates your customers across unrelated software. |
| Integration secrets | API keys as hashes, webhook secrets sealed, delivery logs for 30 days. | The plaintext API keys and webhook secrets in your own deployment, in a secret manager rather than a repository. |
| Your binaries | Nothing. | All of it. The build pipeline, the code, and everything on this page below the network layer. |
On the client, access token, refresh token and licence key go in platform secure storage: Credential Manager, Keychain, Keystore, libsecret. Never a plaintext file next to the executable, and never a log line. Redact tokens and keys from crash reports before they leave the machine, because crash reporters are the most common accidental exfiltration path there is.
Dedicated server
There is no version of Licentry you install and operate yourself. Every account runs on infrastructure we manage. What the dedicated option changes is whose hardware your data sits on, not who does the work.
On a dedicated server your account gets its own machine, provisioned, hardened and patched by us. Your database and licence data live on that box and nowhere else. You do not get shell access to it, and neither does anyone but us. That is deliberate: an unmanaged box drifts, misses patches and eventually becomes the weakest part of your licensing, which defeats the point of having one.
What comes with it:
- Your data on your own machine. Your database, your keys, your licence records, isolated from every other account rather than sharing the platform.
- A licence ceiling set by hardware, not by a plan. Capacity is whatever the machine holds, so the usual plan quota stops being the limit.
- Your own Discord bot. You supply the token, client id and secret; the bot runs on your server under your own name and branding instead of the shared Licentry bot.
- Your own API domain. Point DNS at us and clients talk to your hostname. Without it you use the standard endpoint, which works exactly the same.
- Everything in Studio, plus direct priority support from the person who runs the server.
Key custody, patching, backups, TLS, proxy hardening, replay protection and isolated per-vendor signing keys are our responsibility on every plan, dedicated or not. Nothing in this section is work that lands on you.
Pricing depends on the machine and what you need on it, so it is quoted per setup rather than listed. Each one is maintained personally, which is why slots are limited. Write to [email protected] with your expected licence volume and whether you want a custom bot or domain.
Launch checklist
Before you ship a licensed build to real customers:
- Base URL is configurable, no secrets compiled into the client, no vendor API key anywhere near it.
- Response-signature verification pins keys by
kid, runs before the body is parsed, and fails closed. The clock check is two sided, so setting the system clock back does not let an old signature through. The signature is decoded as rawr||sand its length is checked at 64 bytes. - A response with no
X-Licentry-Sigheader is refused. Grep your own client for a conditional around the verify call:if (header present)is the failure that never shows up in testing. Verified by stripping the header with a local proxy and confirming the product does not run. - Every
401is branched on itserrorfield, not handled by one re-activation. The 401 cases have four different correct recoveries, and three of them are terminal or made worse by re-activating. - Refresh runs on a schedule rather than only on an error, and a heartbeat follows each one.
- A fresh random
clientNoncegoes out on every validate, activate, heartbeat, refresh and logout, and the echo is compared before the status is read. - Activate persists access, refresh,
revocationVersion, product and seq state. - Idempotency key is randomly generated per activation attempt, reused only across retries of that attempt, and never derived from the device or the licence.
- Heartbeat scheduled before
expiresAt, sendsdeviceHashand theserverNoncefrom the previous beat, and handles every 401/403/409/426. 403and426are terminal on every route. No catch-all reaches the licensed path, and no error body is parsed for a field only a success carries.- The product controls are set:
requireBuildToken,requireDpop,maxConcurrentSessionsandengineParams. A product you have not configured enforces none of them, and the product page shows that as a score. - Consider the ones that came later:
payloadKeyif there is anything in your product worth encrypting,requireDpopBodyonce your client putsbhin its proofs,vendorResponseSigningonce your client pins your own keys, andminProtocolVersiononce every copy in the field sends its version. - Device hash is derived from real hardware, salted per product, and emitted as identical lowercase hex on every call. Tested across a reboot, because an unstable recipe revokes a paying customer's session and records a mismatch against them.
- Logout is called on user sign-out and on clean shutdown, with a DPoP proof if the session is bound, and local session state is cleared afterwards, so the concurrency seat is released instead of being evicted later and counted as churn.
session_killedstops;invalid_sessionre-activates once with backoff. Verified by testing both.- Refresh scheduled before
refreshExpiresAt; seq resets to 1 after, and the storedserverNonceis carried across rather than cleared. Tested by running a refresh and the beat that follows it, which is the only way this one shows up. - DPoP proofs fresh per request;
htumatches the exact posted URL; no proof is ever resent. - Offline grace verified against your JWKS with
dev,exp,rvandprodenforced. - Nothing valuable is gated on a boolean. Something the product needs is derived from the live session, and
_session.secretis load bearing rather than ignored, read fresh out of every blob rather than cached at activation. - Checks happen at more than one moment and in more than one place.
- Tokens in platform secure storage; nothing sensitive logged or sent in crash reports; 429s backed off.
- Failure messages give no reason. Nothing in the UI helps someone sort live keys from dead ones.
One more thing to read before you ship, and it is not on this page. The hardening guide in your dashboard covers what our detection measures and at what level, how much slack each replay protection allows, and which of these attacks leave no trace on our side at all. It is open to every signed-in vendor on every plan, including a trial, because a vendor who cannot read it is the vendor whose product gets bypassed.