tether-sdk
v0.1.0
Published
Tether SDK — deployment license verification, enforcement, and health reporting (open source).
Maintainers
Readme
@tether/sdk
Open-source client SDK for Tether — deployment license verification, enforcement, and health reporting for software you sell to clients.
On startup (and on an interval) the SDK builds a deployment fingerprint, sends a signed check-in to your control server, and verifies the server's Ed25519-signed verdict with an embedded public key. If the deployment is not authorized, the SDK locks the app — it refuses to run.
Enforcement is disable-only. The strongest thing this SDK ever does is throw
LicenseInvalidErrorso your app refuses to start. It never deletes data, mutates the database, or harms the host, and the lock reverses automatically the moment the operator authorizes the server. Tether is meant to be disclosed to clients as a license-validation mechanism, not run as a covert kill switch.
Install
npm install @tether/sdk
# or: pnpm add @tether/sdkRequires Node.js ≥ 18.
Quick start
Call guard() as early as possible in your app's startup. If the deployment is
unauthorized it throws and the app stops.
import { guard } from '@tether/sdk';
await guard({
licenseKey: process.env.TETHER_LICENSE_KEY!, // "tth_<keyId>_<secret>"
serverUrl: 'https://tether.example.com', // your control server
serverPublicKey: process.env.TETHER_SERVER_PUBLIC_KEY!, // base64 Ed25519 public key
});
// Reaching this line means the deployment is authorized (or fail-open is configured).
startMyApp();Long-running apps: guard + monitor
Keep a Tether instance so you can also emit heartbeats and report crashes:
import { createTether, LicenseInvalidError } from '@tether/sdk';
const tether = createTether({
licenseKey: process.env.TETHER_LICENSE_KEY!,
serverUrl: 'https://tether.example.com',
serverPublicKey: process.env.TETHER_SERVER_PUBLIC_KEY!,
appId: 'acme-checkout',
});
try {
await tether.guard(); // verify + enforce once at startup
} catch (err) {
if (err instanceof LicenseInvalidError) {
console.error('License invalid:', err.reason, '\nFingerprint:', err.fingerprint);
process.exit(1); // refuse to run — disable, never destroy
}
throw err;
}
// Periodic heartbeats (dead-man's-switch on the server) + crash reporting.
tether.startMonitoring();License key format
A license key is a single string tth_<keyId>_<secret>:
keyId— a public identifier sent on every request so the server can look up the license.secret— the HMAC key used to sign requests. It is never transmitted; possession is proven by the request signature.
How verdicts are trusted
- The SDK signs each request with HMAC-SHA256 over
method + path + timestamp + nonce + body-hash(replay-resistant). - The server returns a
verdictplus an Ed25519 signature over its canonical JSON. - The SDK verifies that signature with
serverPublicKeybefore trusting any field, then checks that the verdict is bound to this license (keyId), this deployment (fingerprint), and this request (nonce), and that it is fresh (issuedAt/expiresAt). A forged or replayed verdict is rejected.
Failure modes
When no verified verdict is available (network error, timeout, bad signature), the
failureMode option decides:
| Mode | Behaviour on error | Use when |
| --------------------- | ------------------------------ | --------------------------- |
| fail-open (default) | app keeps running (degraded) | availability matters most |
| fail-closed | app locks | you want strict enforcement |
Only an explicit, signature-verified unauthorized verdict locks the app in either
mode. fail-open ensures a control-server outage can never take down a paying client.
Configuration
| Option | Type | Default | Description |
| ---------------------- | ------------------------------ | ----------- | -------------------------------------------- |
| licenseKey | string | — | tth_<keyId>_<secret> (required) |
| serverUrl | string | — | Control server base URL (required) |
| serverPublicKey | string | — | base64 Ed25519 public key (required) |
| appId | string | — | App identifier reported on each call |
| failureMode | 'fail-open' \| 'fail-closed' | fail-open | Behaviour when no verified verdict |
| heartbeatIntervalSec | number | 60 | Heartbeat interval (server may override) |
| requestTimeoutMs | number | 5000 | Per-request network timeout |
| clockSkewMs | number | 60000 | Allowed skew when checking verdict freshness |
| captureErrors | boolean | true | Capture & report uncaught errors |
| logLevel | LogLevel | info | silent \| error \| warn \| info \| debug |
API surface
guard(config, deps?)— one-shot verify + enforce (throwsLicenseInvalidError).createTether(config, deps?)/new Tether(...)— orchestrator with:verify()— run a check-in, return a decision (never throws on lock).guard()— verify + enforce (throws on lock).startMonitoring(opts?)/stopMonitoring()— heartbeats + error capture.sendHeartbeat()/reportError(kind, error, ctx?)— manual, best-effort.
collectFingerprint(opts?)and the pure helperscomputeFingerprint,normalizeMac,pickPrimaryMac.- Crypto helpers:
verifyEd25519,signEd25519,canonicalize,generateEd25519KeyPair.
All transport and clock dependencies are injectable (deps.fetcher, deps.now,
deps.fingerprintProvider), so the SDK is fully testable offline.
Honest limitation
Client-side enforcement is inherently bypassable — the client has the code and can edit it. Tether deters casual reselling and honest-but-tempted clients; it does not stop a determined developer. The strongest protection is keeping the truly valuable logic server-side, behind the control plane.
License
Apache-2.0
