Skip to content

Verify a key offline

A licence key can be checked without a network connection: its signature, its product, its major version and its expiry. This page gives the algorithm in any language, then a complete TypeScript example you can run. Check the key offline first, then activate the machine online.

  • The product’s public key: 64 hexadecimal characters, on the product’s page in the Console. Embed it in your build. A key checked against any other public key is rejected.
  • The product’s key prefix, product id and licence domain, from the same page. The domain is <product id>/licence/v1 unless you set another when you added the product.
  • The major version of the build that is checking: a licence covers the major version it was sold for and every earlier one.
  • An Ed25519 verifier that follows RFC 8032. Prefer the strict mode where your library has one.
  1. Clean the text. Remove all whitespace, then require the product’s prefix and remove it. Otherwise the key is malformed.
  2. Decode. The rest is base64url without padding. Refuse text that is not canonical (it must encode back to the same characters). The decoded bytes are the payload followed by a 64-byte signature, so there must be more than 64 of them.
  3. Check the signature. Verify the last 64 bytes against the public key, over the licence domain as UTF-8, one zero byte, then the payload. Check it before you read anything else: an edited key always fails here.
  4. Read the payload. Use the layout and accept only what it allows: format version 1, a known limit kind, a limit of at least 1, and no bytes left over.
  5. Check the product and the major version. The product id must be yours, and the licence’s major version at least the major version of your build.
  6. Check the expiry. An expiry of 0 is perpetual. Otherwise the key is expired when the current time is at or after it.

A key that passes is genuine. It is not proof the licence still holds: revocation, and how many machines are in use, are the API’s answer.

The example uses only Node’s node:crypto. It is checked against a key made by Sealcord’s own encoder, with the published test key from Key format and checks. Replace the four constants with your product’s.

verify-key.ts
import { createPublicKey, verify } from 'node:crypto';
// Your product's values, from its page in the Console.
const PREFIX = 'ACN1-';
const PRODUCT_ID = 'acme-notes';
const LICENCE_DOMAIN = 'acme-notes/licence/v1';
const MAJOR = 1; // the major version of this build of your app
// The product's public key: 64 hex characters, embedded at build time.
const PUBLIC_KEY_HEX = 'ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c';
const SPKI_ED25519_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
const publicKey = createPublicKey({
key: Buffer.concat([SPKI_ED25519_PREFIX, Buffer.from(PUBLIC_KEY_HEX, 'hex')]),
format: 'der',
type: 'spki',
});
export type Licence = {
id: string;
issuedAt: number;
expiresAt: number | null; // Unix seconds, null when perpetual
limitKind: 'seats' | 'machines';
limitCount: number;
major: number;
product: string;
email: string;
};
export type KeyResult =
| { ok: true; licence: Licence }
| { ok: false; reason: 'malformed' | 'bad_signature' | 'wrong_product' | 'expired' };
export function checkKey(input: string, nowSeconds = Math.floor(Date.now() / 1000)): KeyResult {
// 1. Whitespace anywhere is ignored, then the prefix must match.
const key = input.replace(/\s+/g, '');
if (key.startsWith(PREFIX) === false) return { ok: false, reason: 'malformed' };
const text = key.slice(PREFIX.length);
if (/^[A-Za-z0-9_-]+$/.test(text) === false) return { ok: false, reason: 'malformed' };
// 2. Decode, and split off the last 64 bytes: the signature.
const bytes = Buffer.from(text, 'base64url');
const canonical = bytes.toString('base64url') === text;
if (canonical === false || bytes.length <= 64) return { ok: false, reason: 'malformed' };
const payload = bytes.subarray(0, bytes.length - 64);
const signature = bytes.subarray(bytes.length - 64);
// 3. The signature covers the domain, a zero byte, then the payload.
const message = Buffer.concat([Buffer.from(LICENCE_DOMAIN), Buffer.from([0]), payload]);
if (verify(null, message, publicKey, signature) === false) {
return { ok: false, reason: 'bad_signature' };
}
// 4. Read the payload (integers are big-endian).
const licence = readPayload(payload);
if (licence === null) return { ok: false, reason: 'malformed' };
// 5. The product must be yours; a licence covers its major version and every earlier one.
const mine = licence.product === PRODUCT_ID && licence.major >= MAJOR;
if (mine === false) return { ok: false, reason: 'wrong_product' };
// 6. The expiry, if there is one.
if (typeof licence.expiresAt === 'number' && nowSeconds >= licence.expiresAt) {
return { ok: false, reason: 'expired' };
}
return { ok: true, licence };
}
function readPayload(p: Buffer): Licence | null {
try {
let at = 0;
const version = p.readUInt8(at++);
if (version < 1 || version > 1) return null; // only format version 1
const id = p.subarray(at, at + 16).toString('hex');
at += 16;
const issuedAt = Number(p.readBigUInt64BE(at));
at += 8;
const expires = Number(p.readBigUInt64BE(at));
at += 8;
const kind = p.readUInt8(at++);
if (kind > 1) return null;
const limitCount = p.readUInt32BE(at);
at += 4;
const major = p.readUInt16BE(at);
at += 2;
const productLength = p.readUInt8(at++);
const product = p.subarray(at, at + productLength).toString('utf8');
at += productLength;
const emailLength = p.readUInt16BE(at);
at += 2;
const email = p.subarray(at, at + emailLength).toString('utf8');
at += emailLength;
const exact = at === p.length; // no bytes left over, none missing
if (exact === false || limitCount === 0) return null;
return {
id: `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`,
issuedAt,
expiresAt: expires === 0 ? null : expires,
limitKind: kind === 0 ? 'seats' : 'machines',
limitCount,
major,
product,
email,
};
} catch {
return null; // ran past the end
}
}

Call it with what the customer typed:

const result = checkKey(typedKey);
if (result.ok === false) {
showError(result.reason); // malformed, bad_signature, wrong_product or expired
} else {
// Genuine. Now activate this machine: /v1/activations.
}

The example does not check that the expiry is after the issue time, which the encoder guarantees, and it reads the email and product id as text without checking that they are valid UTF-8. Only a Sealcord-signed payload reaches those lines, so this is a matter of strictness, not security.

The algorithm needs only an Ed25519 verifier and big-endian integer reads, so it ports to any language. A Rust crate and a @sealcord/node package for Node are planned; neither is released, and nothing in these docs depends on them. Until they ship, use the algorithm above with your platform’s Ed25519 library, and test it against the example key on Key format and checks.

After the offline check passes, activate the machine: the API counts the seat and answers with a signed verdict, which you keep for when the app is offline.

Do I need a network connection to check a key?

Section titled “Do I need a network connection to check a key?”

No. Steps 1 to 6 use only the key and the public key built into your app. The network is only for activating a machine and refreshing the verdict.

The one the Console shows for the product. After a rotation a new build embeds the new public key; builds already shipped keep the old one and still accept keys signed with it.

Why check the signature before reading the payload?

Section titled “Why check the signature before reading the payload?”

So an edited key is always a bad signature, never a half-read payload, and so your code never trusts a field it has not authenticated.

It is signed, so it cannot be edited. But it is the date at issue time: a subscription’s key usually has no expiry, and the end of a subscription reaches your app through the verdict.

An offline check trusts the machine’s clock for the expiry, so a clock set back can keep an expired key accepted until the next online check. When your app activates or asks for status, the API judges the expiry with its own clock.