Offline integration
For products that must run without a connection. The app activates once online and receives an Ed25519-signed license token. It stores the token and verifies it locally on every launch. It re-checks online periodically (the lease), so revocation and expiry still take effect.
The model
Section titled “The model”- Activate (online, once). Send the typed key, a device fingerprint, and a device name. Receive a signed
token. - Store the token locally, for example in your app’s data folder.
- Verify offline (every launch). Check the signature against the embedded public key, confirm the token is bound to this machine, and check expiry. No network needed.
- Heartbeat (occasionally, online). Re-issue the token to renew the lease (30 days by default). This is what makes revocation reach machines that are already activated.
activate ──► token ──► [store] ──► verify offline on every launch └── periodically ──► heartbeat ──► fresh token1. Activate
Section titled “1. Activate”The TypeScript SDK is delivered as a package file, not through the public npm registry. Download it from theIntegration page of the Waveform app in your store admin (or from your Waveform account), then install it from disk:
npm install ./waveform-licensing-sdk.tgzPrefer not to install anything? Every example below also works as plain HTTP.
import { createClient, computeFingerprint } from "@waveform/licensing";
const license = createClient({ apiBaseUrl: "https://api.waveformanager.com", publicKey: BAKED_PUBLIC_KEY, // from the Integration page; embedded, safe to ship});
const APP_SALT = "your-app-fp-salt-v1"; // a constant compiled into your buildconst fingerprint = computeFingerprint(rawMachineId(), APP_SALT); // see "Fingerprinting" below
const r = await license.activate({ key: enteredKey, fingerprint, deviceName: "Studio Mac", // user-editable; shown in the device manager productId: BAKED_PRODUCT_ID, // the product's internal ID; strongly recommended});
if (r.ok) { saveToken(r.token); // persist locally} else if (r.error === "seat-limit") { // r.devices lists the bound devices. Let the user pick one to free, then retry: await license.deactivate({ key: enteredKey, deviceId: r.devices[0].id }); // ...retry activate} else if (r.error === "activation-limit") { // Not a seat problem: freeing a device will NOT clear this. Say "contact support".} else { // "invalid-key" | "revoked" | "expired" | "blocked" | "product-mismatch" | "network" | ...}Demo (no key)
Section titled “Demo (no key)”const r = await license.startDemo({ productId: BAKED_PRODUCT_ID, fingerprint, deviceName });if (r.ok) saveToken(r.token); // a time-limited demo token; one demo per machine2. Gate the app, every launch
Section titled “2. Gate the app, every launch”Use evaluateOffline. It is the supported entry point, and it is not just a convenience wrapper: it wires together four
defenses that each fail open when you call them separately.
import { evaluateOffline } from "@waveform/licensing";
const gate = evaluateOffline({ token: loadToken(), publicKeyRawB64: BAKED_PUBLIC_KEY, // or [CURRENT, NEXT] during a key rotation expectedProductId: BAKED_PRODUCT_ID, // rejects a sibling product's (or a demo's) token fingerprint, // this machine's sealedMark: loadSealedMark(), // null on first run clockKey: BAKED_CLOCK_HMAC_KEY, // an app constant that seals the clock mark maxOfflineDays: 14, // must reconnect within this window lastOnlineAt: loadLastOnlineAt(),});
saveSealedMark(gate.nextMark); // ALWAYS persist, to every storage location you use
if (gate.allowed) { unlockApp(gate.payload!.feat); if (gate.inGrace) warnLicenseExpiringSoon(); if (gate.clockSuspect) noteClockAnomaly(); // nag, don't crash if (gate.mustReValidateOnline) scheduleHeartbeat();} else { // gate.reason: "bad-signature" | "fingerprint-mismatch" | "product-mismatch" // | "expired" | "online-check-required" | "invalid-claims" | ... showActivationScreen();}In order, it does: strict canonical decode, Ed25519 signature over the raw payload, algorithm and version assertion (blocks downgrade), claim-shape validation, product binding, machine binding, time evaluated at a monotonic clock, and the offline deadline. No network.
Why not call the low-level verifier yourself
Section titled “Why not call the low-level verifier yourself”| If you… | …you lose |
|---|---|
omit fingerprint |
machine binding. A copied license file runs on every machine. (It now fails closed with fingerprint-required, but allowAnyDevice would silently restore the hole.) |
omit expectedProductId |
product binding. One product’s token unlocks another, because signing keys are per merchant. |
pass the system clock as now |
rollback defense. Winding the clock back revives an expired lease. |
skip maxOfflineDays |
the only backstop against a machine that never reconnects and so never learns it was revoked. (Under the deactivate seat policy this backstop is deliberately given up; see Seat policies.) |
verifyLicenseToken stays exported for tooling and tests. It is the low-level primitive, not the gate.
What a machine that never reconnects gets
Section titled “What a machine that never reconnects gets”The lease bounds this on its own. Under the default lease policy the server signs exp at most 30 days out (the server
operator can shorten this) and the client cannot extend it.
| Scenario | Result |
|---|---|
Activates once, offline forever, honest clock, maxOfflineDays: 0 |
Runs 44 days (30-day lease + 14-day grace), then stops with expired |
Same, with maxOfflineDays: 14 |
Stops after 14 days with online-check-required |
| Clock frozen, sealed mark intact | Caught: the mark’s high-water time still advances the verdict |
Clock frozen at iat and the mark deleted on every launch |
Runs indefinitely |
The last row is the honest residual, and it is not specific to this implementation. With the clock pinned and all local
state wiped, every anchor available offline (iat, the sealed mark, lastOnlineAt) is either pinned or absent, and the
process is indistinguishable from a genuine first launch right after activation. No purely offline check can separate those
two cases. What raises the cost in practice:
- Persist
nextMarkto several locations. App data, an OS keychain or registry entry, and a file that does not look like licensing state. “Wipe it every launch” then requires finding all of them first. - Keep
maxOfflineDaysshort (7 to 14). It does not stop the frozen-clock attacker, but it forces every machine with real network access to check in, which is what lets a revocation actually land. - Shorten the lease server-side. A shorter lease cuts the honest never-reconnecting window for every newly issued
token with no client release. Ask the service operator; it applies to
lease-policy products only. - Act on
mustReValidateOnline. It is set when the clock state is untrusted, the lease is in grace, or there is no persisted mark, in other words whenever a recent check-in cannot be proven.
3. Heartbeat: renew the lease
Section titled “3. Heartbeat: renew the lease”Occasionally (on launch if online, or weekly), refresh the token:
const r = await license.heartbeat({ key, fingerprint, productId: BAKED_PRODUCT_ID });if (r.ok) { saveToken(r.token); // new token, lease extended saveLastOnlineAt(nowSec); // feeds evaluateOffline's maxOfflineDays deadline} else if (r.error === "revoked" || r.error === "expired") { // A DEFINITIVE server verdict, not a network failure. Discard the stored token now rather // than riding out the remaining lease and grace (up to 44 days on defaults, which is exactly // the refund-abuse window). clearToken();}Distinguish a definitive revoked, expired, or blocked from network. On a network failure keep the token and retry
later, because a customer with flaky wifi must not be locked out. If you skip heartbeats entirely, the app keeps working
until the lease and grace pass, or until maxOfflineDays elapses, whichever comes first.
The token’s signed hb claim carries the cadence the merchant wants for check-in attempts (3 days by default). It is a
hint for when to try, not a lever on how long the app may run offline; the lease is the only offline dial.
4. Fingerprinting
Section titled “4. Fingerprinting”Compute base64url(HMAC-SHA256(key = appSalt, message = rawMachineId.trim().toLowerCase())). The SDK’s
computeFingerprint(rawMachineId, appSalt) does exactly this. Take rawMachineId from a stable per-OS source:
| OS | Source |
|---|---|
| Windows | Registry HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid |
| macOS | IOPlatformUUID (IOKit), or ioreg -rd1 -c IOPlatformExpertDevice |
| Linux | /etc/machine-id (or /var/lib/dbus/machine-id) |
Keep appSalt constant for a given product: changing it invalidates every existing activation. The raw ID is hashed
locally and never transmitted.
If a customer’s hardware changes enough that the fingerprint changes, send the previous token as priorToken along
with the new fingerprint when activating. The server replaces the old device row in the same step, so the customer does not
see a seat-limit error for their own drifted machine. See POST /v1/activate.
5. Manage devices and release seats
Section titled “5. Manage devices and release seats”Give users a way to see and free their own devices:
const list = await license.listDevices({ key }); // → devices, no fingerprintsawait license.deactivate({ key, fingerprint }); // release THIS machineawait license.deactivate({ key, deviceId }); // release another device by its IDPOST /v1/devices with the caller’s fingerprint marks the matching row current: true, and each row can carry
worksUntil, the date that device keeps working offline. Confirm before releasing, and wipe the local token and key after a
successful self-release.
What next
Section titled “What next”- Choose how seats are freed: Seat policies.
- Verify in another language: Token format.
- Raise the cost of cracking the client: Hardening.
- Every request and error: API reference.