Vendor @keysat/licensing-client to avoid private-repo auth in Docker build

The keysat-client-ts repo is private. Previous builds were succeeding
purely because Docker layer caching reused a node_modules from when
the repo had been accessible — once anything invalidated the
server/package.json or server/package-lock.json hash (the rename did),
npm in a fresh container hit github with no credentials and 404'd.

Fix: copy the built dist/ from server/node_modules/@keysat/licensing-
client/ into vendor/keysat-licensing-client/, strip the prepare/build
scripts (we already have the compiled output), and switch the server
package.json dep to a file: path:

  "@keysat/licensing-client": "file:../vendor/keysat-licensing-client"

Dockerfile now COPY's vendor/ before npm ci. No git, no SSH, no
credentials needed in the build container — and the npm step is
pure-local so it's deterministic.

Side cleanup: dropped the apt-install-git + url.insteadOf gymnastics
that existed solely to work around the now-removed git+https resolution.
The image is slightly smaller (no git in the builder stage). Switched
the npm flag to the modern --omit=dev (the legacy --production printed
a warning).

If keysat-client-ts updates, regenerate vendor/ by:

  cp -r server/node_modules/@keysat/licensing-client/{dist,package.json,LICENSE,README.md} \
        vendor/keysat-licensing-client/
  # then strip prepare/build scripts and devDeps from the copied package.json
  # (or just hand-edit if the upstream package.json hasn't changed)
This commit is contained in:
Keysat
2026-05-08 13:45:12 -05:00
parent 2c2ccfae05
commit 8aaa405843
11 changed files with 1818 additions and 272 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Keysat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+71
View File
@@ -0,0 +1,71 @@
# @keysat/licensing-client
TypeScript / JavaScript client for [`Keysat`](https://github.com/keysat-xyz/keysat) — a self-hosted Bitcoin-paid software licensing server that runs on Start9.
Works in modern browsers and Node 18+. No native dependencies; signature verification is done in pure JS via [`@noble/ed25519`](https://github.com/paulmillr/noble-ed25519).
## What you get
- **Offline verification**: check a license key with just the issuing server's public key. No network.
- **Online validation**: live revocation check and fingerprint binding via the service's `/v1/validate` endpoint.
- **Purchase flow**: kick off a BTCPay checkout and poll for the issued key.
## Install
```bash
npm install @keysat/licensing-client
```
## 5-line offline check
```ts
import { Verifier, PublicKey } from '@keysat/licensing-client'
const verifier = new Verifier(PublicKey.fromPem(ISSUER_PUBKEY_PEM))
const ok = verifier.verify(keyFromUser)
console.log('licensed for product', ok.productId)
```
That's the whole integration. Embed your public key as a string at build time (e.g. Vite's `?raw` import, webpack raw-loader, or just a `const`). If the verifier returns without throwing, the key is real and was issued by you.
## 10-line online check (with revocation + fingerprint)
```ts
import { Client } from '@keysat/licensing-client'
const client = new Client('https://license.example.com')
const result = await client.validate(keyFromUser, 'my-product', machineFingerprint)
if (!result.ok) {
console.error('rejected:', result.reason)
process.exit(1)
}
```
The server enforces revocation live and does trust-on-first-use fingerprint binding, so the same key used from a second machine gets rejected.
## Purchase flow
```ts
const session = await client.startPurchase('my-product')
console.log('pay at:', session.checkoutUrl)
const key = await client.waitForLicense(session.invoiceId)
console.log('got license:', key)
```
`waitForLicense` polls until the BTCPay invoice settles and the service issues a key. It throws if the invoice expires or becomes invalid.
## Browser usage
Everything here works in the browser too. Drop the library into your React/Svelte/Vue app and run offline verification client-side — no server call needed for the common case.
```ts
// Vite: import the PEM as a raw string at build time
import issuerPem from './issuer.pub?raw'
import { Verifier, PublicKey } from '@keysat/licensing-client'
const verifier = new Verifier(PublicKey.fromPem(issuerPem))
```
## License
MIT.
+553
View File
@@ -0,0 +1,553 @@
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
Client: () => Client,
FLAG_FINGERPRINT_BOUND: () => FLAG_FINGERPRINT_BOUND,
FLAG_TRIAL: () => FLAG_TRIAL,
KEY_PREFIX: () => KEY_PREFIX,
KEY_VERSION: () => KEY_VERSION,
KEY_VERSION_V1: () => KEY_VERSION_V1,
KEY_VERSION_V2: () => KEY_VERSION_V2,
LicensingError: () => LicensingError,
PublicKey: () => PublicKey,
Verifier: () => Verifier,
hasEntitlement: () => hasEntitlement,
hashFingerprint: () => hashFingerprint,
isExpiredAt: () => isExpiredAt,
parseLicenseKey: () => parseLicenseKey
});
module.exports = __toCommonJS(index_exports);
// src/verify.ts
var ed = __toESM(require("@noble/ed25519"), 1);
var import_sha512 = require("@noble/hashes/sha512");
// src/errors.ts
var LicensingError = class extends Error {
/**
* Machine-readable reason code. Common values:
* `"bad_format"`, `"bad_encoding"`, `"bad_version"`, `"bad_signature"`,
* `"expired"`, `"server_error"`, `"http_error"`, `"other"`.
*/
code;
constructor(code, message) {
super(message);
this.name = "LicensingError";
this.code = code;
}
};
// src/base32.ts
var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
var DECODE_TABLE = (() => {
const t = {};
for (let i = 0; i < ALPHABET.length; i++) t[ALPHABET[i]] = i;
return t;
})();
function decodeBase32NoPad(input) {
const up = input.toUpperCase();
const out = new Uint8Array(Math.floor(up.length * 5 / 8));
let bits = 0;
let value = 0;
let outPos = 0;
for (let i = 0; i < up.length; i++) {
const ch = up[i];
const v = DECODE_TABLE[ch];
if (v === void 0) {
throw new LicensingError("bad_encoding", `invalid base32 character '${ch}'`);
}
value = value << 5 | v;
bits += 5;
if (bits >= 8) {
bits -= 8;
out[outPos++] = value >> bits & 255;
}
}
return out.subarray(0, outPos);
}
// src/key.ts
var KEY_PREFIX = "LIC1";
var KEY_VERSION_V1 = 1;
var KEY_VERSION_V2 = 2;
var KEY_VERSION = KEY_VERSION_V2;
var FLAG_FINGERPRINT_BOUND = 1;
var FLAG_TRIAL = 2;
var PAYLOAD_V1_LEN = 74;
var PAYLOAD_V2_HEAD_LEN = 83;
var SIGNATURE_LEN = 64;
function isExpiredAt(payload, nowUnixSeconds) {
return payload.expiresAt !== 0 && nowUnixSeconds >= payload.expiresAt;
}
function hasEntitlement(payload, slug) {
return payload.entitlements.includes(slug);
}
function parseLicenseKey(raw) {
const trimmed = raw.trim();
const firstDash = trimmed.indexOf("-");
if (firstDash < 0) throw new LicensingError("bad_format", "key is missing prefix delimiter");
const prefix = trimmed.slice(0, firstDash);
if (prefix !== KEY_PREFIX) throw new LicensingError("bad_format", `unknown key prefix '${prefix}'`);
const body = trimmed.slice(firstDash + 1);
const lastDash = body.lastIndexOf("-");
if (lastDash < 0) throw new LicensingError("bad_format", "key is missing signature delimiter");
const payloadB32 = body.slice(0, lastDash);
const signatureB32 = body.slice(lastDash + 1);
const payloadBytes = decodeBase32NoPad(payloadB32);
const signature = decodeBase32NoPad(signatureB32);
if (signature.length !== SIGNATURE_LEN) {
throw new LicensingError(
"bad_format",
`signature is ${signature.length} bytes; expected ${SIGNATURE_LEN}`
);
}
if (payloadBytes.length < 1) {
throw new LicensingError("bad_format", "empty payload");
}
const version = payloadBytes[0];
let payload;
switch (version) {
case KEY_VERSION_V1:
payload = parseV1(payloadBytes);
break;
case KEY_VERSION_V2:
payload = parseV2(payloadBytes);
break;
default:
throw new LicensingError("bad_version", `unsupported key version ${version}`);
}
return {
payload,
signedBytes: payloadBytes,
signature
};
}
function parseV1(payloadBytes) {
if (payloadBytes.length !== PAYLOAD_V1_LEN) {
throw new LicensingError(
"bad_format",
`v1 payload is ${payloadBytes.length} bytes; expected ${PAYLOAD_V1_LEN}`
);
}
const flags = payloadBytes[1];
const productId = payloadBytes.slice(2, 18);
const licenseId = payloadBytes.slice(18, 34);
const issuedAt = readBigEndianI64(payloadBytes, 34);
const fingerprintHash = payloadBytes.slice(42, 74);
return {
version: KEY_VERSION_V1,
flags,
productId,
licenseId,
issuedAt,
expiresAt: 0,
fingerprintHash,
entitlements: [],
productUuid: uuidString(productId),
licenseUuid: uuidString(licenseId),
isFingerprintBound: (flags & FLAG_FINGERPRINT_BOUND) !== 0,
isTrial: (flags & FLAG_TRIAL) !== 0
};
}
function parseV2(payloadBytes) {
if (payloadBytes.length < PAYLOAD_V2_HEAD_LEN) {
throw new LicensingError(
"bad_format",
`v2 payload is ${payloadBytes.length} bytes; expected >= ${PAYLOAD_V2_HEAD_LEN}`
);
}
const flags = payloadBytes[1];
const productId = payloadBytes.slice(2, 18);
const licenseId = payloadBytes.slice(18, 34);
const issuedAt = readBigEndianI64(payloadBytes, 34);
const expiresAt = readBigEndianI64(payloadBytes, 42);
const fingerprintHash = payloadBytes.slice(50, 82);
const numEntitlements = payloadBytes[82];
const entitlements = [];
let cursor = PAYLOAD_V2_HEAD_LEN;
const decoder = new TextDecoder("utf-8", { fatal: true });
for (let i = 0; i < numEntitlements; i++) {
if (cursor >= payloadBytes.length) {
throw new LicensingError("bad_format", "truncated entitlement list");
}
const len = payloadBytes[cursor];
cursor += 1;
if (cursor + len > payloadBytes.length) {
throw new LicensingError("bad_format", "truncated entitlement");
}
try {
entitlements.push(decoder.decode(payloadBytes.slice(cursor, cursor + len)));
} catch {
throw new LicensingError("bad_format", "entitlement not utf-8");
}
cursor += len;
}
if (cursor !== payloadBytes.length) {
throw new LicensingError("bad_format", "trailing bytes in payload");
}
return {
version: KEY_VERSION_V2,
flags,
productId,
licenseId,
issuedAt,
expiresAt,
fingerprintHash,
entitlements,
productUuid: uuidString(productId),
licenseUuid: uuidString(licenseId),
isFingerprintBound: (flags & FLAG_FINGERPRINT_BOUND) !== 0,
isTrial: (flags & FLAG_TRIAL) !== 0
};
}
function readBigEndianI64(buf, offset) {
const view = new DataView(buf.buffer, buf.byteOffset + offset, 8);
const hi = view.getInt32(0, false);
const lo = view.getUint32(4, false);
return hi * 2 ** 32 + lo;
}
function uuidString(b) {
const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
// src/fingerprint.ts
var import_sha256 = require("@noble/hashes/sha256");
function hashFingerprint(raw) {
return (0, import_sha256.sha256)(new TextEncoder().encode(raw));
}
// src/verify.ts
ed.etc.sha512Sync = (...m) => (0, import_sha512.sha512)(ed.etc.concatBytes(...m));
var Verifier = class {
pubkey;
constructor(pubkey) {
this.pubkey = pubkey;
}
/** Verify a license key string. Throws on any failure. */
verify(keyStr) {
const key = parseLicenseKey(keyStr);
const ok = ed.verify(key.signature, key.signedBytes, this.pubkey.raw);
if (!ok) throw new LicensingError("bad_signature", "signature did not verify");
return {
payload: key.payload,
licenseId: key.payload.licenseUuid,
productId: key.payload.productUuid
};
}
/**
* Verify AND enforce that, if the key is fingerprint-bound, the given
* fingerprint matches. If the key is not bound, the fingerprint is
* ignored. Throws on any failure.
*/
verifyWithFingerprint(keyStr, fingerprint) {
const result = this.verify(keyStr);
if (result.payload.isFingerprintBound) {
const expected = hashFingerprint(fingerprint);
const stored = result.payload.fingerprintHash;
if (!equalBytes(expected, stored)) {
throw new LicensingError("bad_signature", "fingerprint does not match bound key");
}
}
return result;
}
/**
* Verify a key and additionally reject it with an `expired` error if
* `nowUnixSeconds` is at or past its `expiresAt`. Perpetual keys
* (`expiresAt === 0`) are accepted regardless of `nowUnixSeconds`. This is
* offline-only — no grace window logic; use `Client.validate` for that.
*/
verifyWithTime(keyStr, nowUnixSeconds) {
const result = this.verify(keyStr);
if (isExpiredAt(result.payload, nowUnixSeconds)) {
throw new LicensingError("expired", "license has expired");
}
return result;
}
};
function equalBytes(a, b) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff === 0;
}
// src/online.ts
var Client = class {
base;
constructor(baseUrl) {
this.base = baseUrl.replace(/\/+$/, "");
}
/** The normalized base URL this client is pinned to. */
baseUrl() {
return this.base;
}
/** Fetch the server's PEM-encoded public key. */
async fetchPubkeyPem() {
const data = await this.get("/v1/pubkey");
return data.public_key_pem;
}
/**
* Server-authoritative validation. Returns the full response including
* expiry / entitlements / seat fields introduced in v2.
*
* Two-argument form kept for call-site compatibility with earlier SDK
* versions; pass an options object for the full set of fields.
*/
async validate(key, productSlugOrOptions, fingerprint) {
const opts = typeof productSlugOrOptions === "string" ? { productSlug: productSlugOrOptions, fingerprint } : productSlugOrOptions ?? {};
const raw = await this.post("/v1/validate", {
key,
product_slug: opts.productSlug,
fingerprint: opts.fingerprint,
hostname: opts.hostname,
platform: opts.platform
});
return this.toValidateResponse(raw);
}
/** Lightweight heartbeat. Server updates `last_heartbeat_at`. */
async heartbeat(key, fingerprint) {
const raw = await this.post("/v1/machines/heartbeat", {
key,
fingerprint
});
return this.toMachineResponse(raw);
}
/** Explicitly activate a seat for the given fingerprint. */
async activate(key, fingerprint, opts = {}) {
const raw = await this.post("/v1/machines/activate", {
key,
fingerprint,
hostname: opts.hostname,
platform: opts.platform
});
return this.toMachineResponse(raw);
}
/** Free a seat held by the given fingerprint. */
async deactivate(key, fingerprint, reason) {
const raw = await this.post("/v1/machines/deactivate", {
key,
fingerprint,
reason
});
return this.toMachineResponse(raw);
}
/** Start a purchase. Returns the checkout URL and invoice id. */
async startPurchase(productSlug, opts = {}) {
const raw = await this.post("/v1/purchase", {
product: productSlug,
buyer_email: opts.buyerEmail,
buyer_note: opts.buyerNote,
redirect_url: opts.redirectUrl,
code: opts.code
});
return {
invoiceId: raw.invoice_id,
btcpayInvoiceId: raw.btcpay_invoice_id,
checkoutUrl: raw.checkout_url,
amountSats: raw.amount_sats,
pollUrl: raw.poll_url
};
}
/**
* Redeem a `free_license` code: bypass BTCPay entirely and receive the
* signed license key directly. Throws if the code is unknown / disabled
* / expired / wrong product / not a free_license code, or if the cap
* has been reached.
*/
async redeemFreeLicense(productSlug, code, opts = {}) {
const raw = await this.post("/v1/redeem", {
product: productSlug,
code,
buyer_email: opts.buyerEmail,
buyer_note: opts.buyerNote
});
return {
licenseId: raw.license_id,
licenseKey: raw.license_key,
invoiceId: raw.invoice_id,
redemptionId: raw.redemption_id
};
}
/** Poll a purchase by its invoice id. */
async pollPurchase(invoiceId) {
const raw = await this.get(
`/v1/purchase/${encodeURIComponent(invoiceId)}`
);
return {
invoiceId: raw.invoice_id,
status: raw.status,
productId: raw.product_id,
amountSats: raw.amount_sats,
licenseKey: raw.license_key ?? void 0,
licenseId: raw.license_id ?? void 0
};
}
/**
* Convenience: open the checkout, poll until a license key is issued,
* then return it. Suitable for CLI usage or for an app UI that shows a
* spinner while the buyer pays.
*/
async waitForLicense(invoiceId, options = {}) {
const interval = options.intervalMs ?? 5e3;
const deadline = options.timeoutMs ? Date.now() + options.timeoutMs : Infinity;
while (true) {
const poll = await this.pollPurchase(invoiceId);
if (poll.licenseKey) return poll.licenseKey;
if (poll.status === "expired" || poll.status === "invalid") {
throw new LicensingError("server_error", `invoice ended in status ${poll.status}`);
}
if (Date.now() > deadline) {
throw new LicensingError("server_error", "timed out waiting for license issuance");
}
await sleep(interval);
}
}
// --- internals ---
toValidateResponse(raw) {
const entitlements = Array.isArray(raw.entitlements) ? raw.entitlements.filter((x) => typeof x === "string") : void 0;
return {
ok: !!raw.ok,
reason: raw.reason,
licenseId: raw.license_id,
productId: raw.product_id,
productSlug: raw.product_slug,
issuedAt: raw.issued_at,
expiresAt: raw.expires_at,
graceUntil: raw.grace_until,
inGracePeriod: raw.in_grace_period,
isTrial: raw.is_trial,
entitlements,
status: raw.status,
machineId: raw.machine_id,
maxMachines: raw.max_machines
};
}
toMachineResponse(raw) {
return {
ok: !!raw.ok,
reason: raw.reason,
machineId: raw.machine_id,
activeCount: raw.active_count,
maxMachines: raw.max_machines
};
}
async get(path) {
return this.request(path, { method: "GET" });
}
async post(path, body) {
return this.request(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body)
});
}
async request(path, init) {
let resp;
try {
resp = await fetch(`${this.base}${path}`, init);
} catch (e) {
throw new LicensingError("http_error", e instanceof Error ? e.message : String(e));
}
const text = await resp.text();
if (!resp.ok) {
throw new LicensingError("server_error", `HTTP ${resp.status}: ${text}`);
}
try {
return JSON.parse(text);
} catch {
throw new LicensingError("server_error", `non-JSON response: ${text}`);
}
}
};
function sleep(ms) {
return new Promise((res) => setTimeout(res, ms));
}
// src/pubkey.ts
var PublicKey = class _PublicKey {
/** Raw 32-byte Ed25519 public key material. */
raw;
constructor(raw) {
if (raw.length !== 32) {
throw new LicensingError(
"bad_format",
`public key must be 32 bytes; got ${raw.length}`
);
}
this.raw = raw;
}
/** Parse a PEM blob as emitted by the service. */
static fromPem(pem) {
const stripped = pem.replace(/-----BEGIN [^-]+-----/g, "").replace(/-----END [^-]+-----/g, "").replace(/\s+/g, "");
if (!stripped) {
throw new LicensingError("bad_format", "empty PEM input");
}
const der = base64Decode(stripped);
if (der.length < 32) {
throw new LicensingError("bad_format", "PEM body too short to contain a public key");
}
const raw = der.slice(der.length - 32);
return new _PublicKey(raw);
}
/** Construct from raw bytes (no PEM envelope). */
static fromBytes(bytes) {
return new _PublicKey(bytes);
}
};
function base64Decode(b64) {
const nodeBuffer = globalThis.Buffer;
if (nodeBuffer) {
return new Uint8Array(nodeBuffer.from(b64, "base64"));
}
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Client,
FLAG_FINGERPRINT_BOUND,
FLAG_TRIAL,
KEY_PREFIX,
KEY_VERSION,
KEY_VERSION_V1,
KEY_VERSION_V2,
LicensingError,
PublicKey,
Verifier,
hasEntitlement,
hashFingerprint,
isExpiredAt,
parseLicenseKey
});
+306
View File
@@ -0,0 +1,306 @@
/**
* License-key parsing. Matches the service's wire format exactly.
*
* ## Wire format
*
* A key string looks like `LIC1-<payload_b32>-<signature_b32>`. Both halves
* are Crockford base32 (no padding) of the raw bytes.
*
* ### v1 payload (74 bytes, fixed)
*
* ```text
* offset size field
* 0 1 version = 1
* 1 1 flags
* 2 16 product_id (UUID bytes)
* 18 16 license_id (UUID bytes)
* 34 8 issued_at (i64 unix seconds, big-endian)
* 42 32 fingerprint_hash (SHA-256, or all-zero)
* ```
*
* ### v2 payload (83 bytes + variable entitlements)
*
* ```text
* offset size field
* 0 1 version = 2
* 1 1 flags
* 2 16 product_id
* 18 16 license_id
* 34 8 issued_at
* 42 8 expires_at (i64, 0 = perpetual)
* 50 32 fingerprint_hash
* 82 1 num_entitlements (u8)
* 83 * entitlements — each: [u8 len][len utf-8 bytes]
* ```
*
* Clients verifying a v1 key treat `expiresAt` as 0 and `entitlements` as
* empty, so application code can branch on flags / fields uniformly.
*/
declare const KEY_PREFIX = "LIC1";
/** v1 format identifier. */
declare const KEY_VERSION_V1 = 1;
/** v2 format identifier. */
declare const KEY_VERSION_V2 = 2;
/** Highest format version this client understands. */
declare const KEY_VERSION = 2;
/** Set when the key is bound to a specific machine fingerprint hash. */
declare const FLAG_FINGERPRINT_BOUND = 1;
/** Set on trial keys. */
declare const FLAG_TRIAL = 2;
/** Decoded fields of the signed payload. */
interface LicensePayload {
/** Format version (1 or 2). */
version: number;
/** Feature flags. */
flags: number;
/** Raw 16-byte product id (UUID). */
productId: Uint8Array;
/** Raw 16-byte license id (UUID). */
licenseId: Uint8Array;
/** Unix seconds issued. */
issuedAt: number;
/** Unix seconds expiry; `0` for perpetual. Always `0` on v1 keys. */
expiresAt: number;
/** SHA-256 hash of the bound machine fingerprint, or all-zero. */
fingerprintHash: Uint8Array;
/** Entitlement slugs granted by this license. Empty on v1 keys. */
entitlements: string[];
/** Product UUID in canonical string form. */
productUuid: string;
/** License UUID in canonical string form. */
licenseUuid: string;
/** True if the key is fingerprint-bound. */
isFingerprintBound: boolean;
/** True if the key is flagged as a trial. */
isTrial: boolean;
}
/** A parsed (not yet verified) license key. */
interface LicenseKey {
payload: LicensePayload;
/**
* Raw payload bytes (what the server signed over). Length is 74 on v1,
* `>= 83` on v2.
*/
signedBytes: Uint8Array;
/** Raw 64-byte signature. */
signature: Uint8Array;
}
/** True if `nowUnixSeconds` is at or after the key's `expiresAt`. */
declare function isExpiredAt(payload: LicensePayload, nowUnixSeconds: number): boolean;
/** True if the license grants the given entitlement slug. */
declare function hasEntitlement(payload: LicensePayload, slug: string): boolean;
/** Parse a `LIC1-...-...` string. Does NOT verify. */
declare function parseLicenseKey(raw: string): LicenseKey;
/**
* Issuer public key. Accepts either raw 32-byte Ed25519 key material or a
* PEM-encoded SubjectPublicKeyInfo blob (which is what the service returns
* from `/v1/pubkey`).
*/
/** Parsed Ed25519 public key, ready for signature verification. */
declare class PublicKey {
/** Raw 32-byte Ed25519 public key material. */
readonly raw: Uint8Array;
constructor(raw: Uint8Array);
/** Parse a PEM blob as emitted by the service. */
static fromPem(pem: string): PublicKey;
/** Construct from raw bytes (no PEM envelope). */
static fromBytes(bytes: Uint8Array): PublicKey;
}
/** Offline Ed25519 signature verification. */
interface VerifyOk {
/** Parsed payload fields. */
payload: LicensePayload;
/** License UUID as a canonical string. */
licenseId: string;
/** Product UUID as a canonical string. */
productId: string;
}
/** Verifies license keys against a single issuing server's public key. */
declare class Verifier {
private pubkey;
constructor(pubkey: PublicKey);
/** Verify a license key string. Throws on any failure. */
verify(keyStr: string): VerifyOk;
/**
* Verify AND enforce that, if the key is fingerprint-bound, the given
* fingerprint matches. If the key is not bound, the fingerprint is
* ignored. Throws on any failure.
*/
verifyWithFingerprint(keyStr: string, fingerprint: string): VerifyOk;
/**
* Verify a key and additionally reject it with an `expired` error if
* `nowUnixSeconds` is at or past its `expiresAt`. Perpetual keys
* (`expiresAt === 0`) are accepted regardless of `nowUnixSeconds`. This is
* offline-only — no grace window logic; use `Client.validate` for that.
*/
verifyWithTime(keyStr: string, nowUnixSeconds: number): VerifyOk;
}
/**
* Online operations against a running `licensing-service` instance.
*
* All methods use the global `fetch` available in Node 18+ and every modern
* browser. No additional runtime required.
*/
interface ValidateResponse {
ok: boolean;
/**
* Machine-readable reason on failure. One of:
* `bad_format`, `bad_signature`, `not_found`, `revoked`, `suspended`,
* `expired`, `product_mismatch`, `fingerprint_mismatch`,
* `too_many_machines`, `rate_limited`, `invalid_state`.
*/
reason?: string;
licenseId?: string;
productId?: string;
productSlug?: string;
issuedAt?: string;
/** Expiry timestamp (RFC 3339) if the license has one. */
expiresAt?: string;
/** End of the grace window (RFC 3339) when in a grace period. */
graceUntil?: string;
/** True when the key is past `expiresAt` but still inside the grace window. */
inGracePeriod?: boolean;
/** True if this license is flagged as a trial. */
isTrial?: boolean;
/** Entitlement slugs granted by the license. */
entitlements?: string[];
/** License status string: `active`, `suspended`, `revoked`. */
status?: string;
/** Machine id created or matched by this call (when fingerprint was sent). */
machineId?: string;
/** Seat cap: `0` unlimited, `1` single-seat, `n` n-seat. */
maxMachines?: number;
}
interface ValidateOptions {
/** Product slug the caller expects the key to cover. */
productSlug?: string;
/** Raw machine fingerprint; enables seat binding / cap enforcement. */
fingerprint?: string;
/** Client-supplied hostname, stored against the machine row on activation. */
hostname?: string;
/** Client-supplied platform descriptor, e.g. `'linux-x86_64'`. */
platform?: string;
}
interface MachineResponse {
ok: boolean;
reason?: string;
machineId?: string;
activeCount?: number;
maxMachines?: number;
}
interface PurchaseSession {
/** Our internal invoice id — use with `pollPurchase`. */
invoiceId: string;
/** BTCPay's invoice id (opaque). */
btcpayInvoiceId: string;
/** URL to open in the buyer's browser to pay. */
checkoutUrl: string;
/** Price in satoshis. */
amountSats: number;
/** Where the service recommends polling. */
pollUrl: string;
}
interface PollResponse {
invoiceId: string;
/** `pending | settled | expired | invalid`. */
status: string;
productId: string;
amountSats: number;
/** Populated once the license has been issued. */
licenseKey?: string;
licenseId?: string;
}
interface StartPurchaseOptions {
/** Optional email for the receipt. */
buyerEmail?: string;
/** Optional URL the buyer should be returned to after payment. */
redirectUrl?: string;
/** Optional discount / referral code. */
code?: string;
/** Optional buyer note recorded on the invoice (admin-visible). */
buyerNote?: string;
}
interface RedeemFreeOptions {
/** Optional email recorded on the synthetic invoice + license. */
buyerEmail?: string;
/** Optional buyer note. */
buyerNote?: string;
}
interface RedeemFreeResponse {
licenseId: string;
/** The fully-signed license key, ready for offline verification. */
licenseKey: string;
invoiceId: string;
redemptionId: string;
}
/** An HTTP client pinned to one licensing-service base URL. */
declare class Client {
private base;
constructor(baseUrl: string);
/** The normalized base URL this client is pinned to. */
baseUrl(): string;
/** Fetch the server's PEM-encoded public key. */
fetchPubkeyPem(): Promise<string>;
/**
* Server-authoritative validation. Returns the full response including
* expiry / entitlements / seat fields introduced in v2.
*
* Two-argument form kept for call-site compatibility with earlier SDK
* versions; pass an options object for the full set of fields.
*/
validate(key: string, productSlugOrOptions?: string | ValidateOptions, fingerprint?: string): Promise<ValidateResponse>;
/** Lightweight heartbeat. Server updates `last_heartbeat_at`. */
heartbeat(key: string, fingerprint: string): Promise<MachineResponse>;
/** Explicitly activate a seat for the given fingerprint. */
activate(key: string, fingerprint: string, opts?: {
hostname?: string;
platform?: string;
}): Promise<MachineResponse>;
/** Free a seat held by the given fingerprint. */
deactivate(key: string, fingerprint: string, reason?: string): Promise<MachineResponse>;
/** Start a purchase. Returns the checkout URL and invoice id. */
startPurchase(productSlug: string, opts?: StartPurchaseOptions): Promise<PurchaseSession>;
/**
* Redeem a `free_license` code: bypass BTCPay entirely and receive the
* signed license key directly. Throws if the code is unknown / disabled
* / expired / wrong product / not a free_license code, or if the cap
* has been reached.
*/
redeemFreeLicense(productSlug: string, code: string, opts?: RedeemFreeOptions): Promise<RedeemFreeResponse>;
/** Poll a purchase by its invoice id. */
pollPurchase(invoiceId: string): Promise<PollResponse>;
/**
* Convenience: open the checkout, poll until a license key is issued,
* then return it. Suitable for CLI usage or for an app UI that shows a
* spinner while the buyer pays.
*/
waitForLicense(invoiceId: string, options?: {
intervalMs?: number;
timeoutMs?: number;
}): Promise<string>;
private toValidateResponse;
private toMachineResponse;
private get;
private post;
private request;
}
/** Hash a raw fingerprint string to the 32-byte form embedded in keys. */
declare function hashFingerprint(raw: string): Uint8Array;
/** All errors thrown by this library inherit from `LicensingError`. */
declare class LicensingError extends Error {
/**
* Machine-readable reason code. Common values:
* `"bad_format"`, `"bad_encoding"`, `"bad_version"`, `"bad_signature"`,
* `"expired"`, `"server_error"`, `"http_error"`, `"other"`.
*/
readonly code: string;
constructor(code: string, message: string);
}
export { Client, FLAG_FINGERPRINT_BOUND, FLAG_TRIAL, KEY_PREFIX, KEY_VERSION, KEY_VERSION_V1, KEY_VERSION_V2, type LicenseKey, type LicensePayload, LicensingError, type MachineResponse, type PollResponse, PublicKey, type PurchaseSession, type RedeemFreeOptions, type RedeemFreeResponse, type StartPurchaseOptions, type ValidateOptions, type ValidateResponse, Verifier, type VerifyOk, hasEntitlement, hashFingerprint, isExpiredAt, parseLicenseKey };
+306
View File
@@ -0,0 +1,306 @@
/**
* License-key parsing. Matches the service's wire format exactly.
*
* ## Wire format
*
* A key string looks like `LIC1-<payload_b32>-<signature_b32>`. Both halves
* are Crockford base32 (no padding) of the raw bytes.
*
* ### v1 payload (74 bytes, fixed)
*
* ```text
* offset size field
* 0 1 version = 1
* 1 1 flags
* 2 16 product_id (UUID bytes)
* 18 16 license_id (UUID bytes)
* 34 8 issued_at (i64 unix seconds, big-endian)
* 42 32 fingerprint_hash (SHA-256, or all-zero)
* ```
*
* ### v2 payload (83 bytes + variable entitlements)
*
* ```text
* offset size field
* 0 1 version = 2
* 1 1 flags
* 2 16 product_id
* 18 16 license_id
* 34 8 issued_at
* 42 8 expires_at (i64, 0 = perpetual)
* 50 32 fingerprint_hash
* 82 1 num_entitlements (u8)
* 83 * entitlements — each: [u8 len][len utf-8 bytes]
* ```
*
* Clients verifying a v1 key treat `expiresAt` as 0 and `entitlements` as
* empty, so application code can branch on flags / fields uniformly.
*/
declare const KEY_PREFIX = "LIC1";
/** v1 format identifier. */
declare const KEY_VERSION_V1 = 1;
/** v2 format identifier. */
declare const KEY_VERSION_V2 = 2;
/** Highest format version this client understands. */
declare const KEY_VERSION = 2;
/** Set when the key is bound to a specific machine fingerprint hash. */
declare const FLAG_FINGERPRINT_BOUND = 1;
/** Set on trial keys. */
declare const FLAG_TRIAL = 2;
/** Decoded fields of the signed payload. */
interface LicensePayload {
/** Format version (1 or 2). */
version: number;
/** Feature flags. */
flags: number;
/** Raw 16-byte product id (UUID). */
productId: Uint8Array;
/** Raw 16-byte license id (UUID). */
licenseId: Uint8Array;
/** Unix seconds issued. */
issuedAt: number;
/** Unix seconds expiry; `0` for perpetual. Always `0` on v1 keys. */
expiresAt: number;
/** SHA-256 hash of the bound machine fingerprint, or all-zero. */
fingerprintHash: Uint8Array;
/** Entitlement slugs granted by this license. Empty on v1 keys. */
entitlements: string[];
/** Product UUID in canonical string form. */
productUuid: string;
/** License UUID in canonical string form. */
licenseUuid: string;
/** True if the key is fingerprint-bound. */
isFingerprintBound: boolean;
/** True if the key is flagged as a trial. */
isTrial: boolean;
}
/** A parsed (not yet verified) license key. */
interface LicenseKey {
payload: LicensePayload;
/**
* Raw payload bytes (what the server signed over). Length is 74 on v1,
* `>= 83` on v2.
*/
signedBytes: Uint8Array;
/** Raw 64-byte signature. */
signature: Uint8Array;
}
/** True if `nowUnixSeconds` is at or after the key's `expiresAt`. */
declare function isExpiredAt(payload: LicensePayload, nowUnixSeconds: number): boolean;
/** True if the license grants the given entitlement slug. */
declare function hasEntitlement(payload: LicensePayload, slug: string): boolean;
/** Parse a `LIC1-...-...` string. Does NOT verify. */
declare function parseLicenseKey(raw: string): LicenseKey;
/**
* Issuer public key. Accepts either raw 32-byte Ed25519 key material or a
* PEM-encoded SubjectPublicKeyInfo blob (which is what the service returns
* from `/v1/pubkey`).
*/
/** Parsed Ed25519 public key, ready for signature verification. */
declare class PublicKey {
/** Raw 32-byte Ed25519 public key material. */
readonly raw: Uint8Array;
constructor(raw: Uint8Array);
/** Parse a PEM blob as emitted by the service. */
static fromPem(pem: string): PublicKey;
/** Construct from raw bytes (no PEM envelope). */
static fromBytes(bytes: Uint8Array): PublicKey;
}
/** Offline Ed25519 signature verification. */
interface VerifyOk {
/** Parsed payload fields. */
payload: LicensePayload;
/** License UUID as a canonical string. */
licenseId: string;
/** Product UUID as a canonical string. */
productId: string;
}
/** Verifies license keys against a single issuing server's public key. */
declare class Verifier {
private pubkey;
constructor(pubkey: PublicKey);
/** Verify a license key string. Throws on any failure. */
verify(keyStr: string): VerifyOk;
/**
* Verify AND enforce that, if the key is fingerprint-bound, the given
* fingerprint matches. If the key is not bound, the fingerprint is
* ignored. Throws on any failure.
*/
verifyWithFingerprint(keyStr: string, fingerprint: string): VerifyOk;
/**
* Verify a key and additionally reject it with an `expired` error if
* `nowUnixSeconds` is at or past its `expiresAt`. Perpetual keys
* (`expiresAt === 0`) are accepted regardless of `nowUnixSeconds`. This is
* offline-only — no grace window logic; use `Client.validate` for that.
*/
verifyWithTime(keyStr: string, nowUnixSeconds: number): VerifyOk;
}
/**
* Online operations against a running `licensing-service` instance.
*
* All methods use the global `fetch` available in Node 18+ and every modern
* browser. No additional runtime required.
*/
interface ValidateResponse {
ok: boolean;
/**
* Machine-readable reason on failure. One of:
* `bad_format`, `bad_signature`, `not_found`, `revoked`, `suspended`,
* `expired`, `product_mismatch`, `fingerprint_mismatch`,
* `too_many_machines`, `rate_limited`, `invalid_state`.
*/
reason?: string;
licenseId?: string;
productId?: string;
productSlug?: string;
issuedAt?: string;
/** Expiry timestamp (RFC 3339) if the license has one. */
expiresAt?: string;
/** End of the grace window (RFC 3339) when in a grace period. */
graceUntil?: string;
/** True when the key is past `expiresAt` but still inside the grace window. */
inGracePeriod?: boolean;
/** True if this license is flagged as a trial. */
isTrial?: boolean;
/** Entitlement slugs granted by the license. */
entitlements?: string[];
/** License status string: `active`, `suspended`, `revoked`. */
status?: string;
/** Machine id created or matched by this call (when fingerprint was sent). */
machineId?: string;
/** Seat cap: `0` unlimited, `1` single-seat, `n` n-seat. */
maxMachines?: number;
}
interface ValidateOptions {
/** Product slug the caller expects the key to cover. */
productSlug?: string;
/** Raw machine fingerprint; enables seat binding / cap enforcement. */
fingerprint?: string;
/** Client-supplied hostname, stored against the machine row on activation. */
hostname?: string;
/** Client-supplied platform descriptor, e.g. `'linux-x86_64'`. */
platform?: string;
}
interface MachineResponse {
ok: boolean;
reason?: string;
machineId?: string;
activeCount?: number;
maxMachines?: number;
}
interface PurchaseSession {
/** Our internal invoice id — use with `pollPurchase`. */
invoiceId: string;
/** BTCPay's invoice id (opaque). */
btcpayInvoiceId: string;
/** URL to open in the buyer's browser to pay. */
checkoutUrl: string;
/** Price in satoshis. */
amountSats: number;
/** Where the service recommends polling. */
pollUrl: string;
}
interface PollResponse {
invoiceId: string;
/** `pending | settled | expired | invalid`. */
status: string;
productId: string;
amountSats: number;
/** Populated once the license has been issued. */
licenseKey?: string;
licenseId?: string;
}
interface StartPurchaseOptions {
/** Optional email for the receipt. */
buyerEmail?: string;
/** Optional URL the buyer should be returned to after payment. */
redirectUrl?: string;
/** Optional discount / referral code. */
code?: string;
/** Optional buyer note recorded on the invoice (admin-visible). */
buyerNote?: string;
}
interface RedeemFreeOptions {
/** Optional email recorded on the synthetic invoice + license. */
buyerEmail?: string;
/** Optional buyer note. */
buyerNote?: string;
}
interface RedeemFreeResponse {
licenseId: string;
/** The fully-signed license key, ready for offline verification. */
licenseKey: string;
invoiceId: string;
redemptionId: string;
}
/** An HTTP client pinned to one licensing-service base URL. */
declare class Client {
private base;
constructor(baseUrl: string);
/** The normalized base URL this client is pinned to. */
baseUrl(): string;
/** Fetch the server's PEM-encoded public key. */
fetchPubkeyPem(): Promise<string>;
/**
* Server-authoritative validation. Returns the full response including
* expiry / entitlements / seat fields introduced in v2.
*
* Two-argument form kept for call-site compatibility with earlier SDK
* versions; pass an options object for the full set of fields.
*/
validate(key: string, productSlugOrOptions?: string | ValidateOptions, fingerprint?: string): Promise<ValidateResponse>;
/** Lightweight heartbeat. Server updates `last_heartbeat_at`. */
heartbeat(key: string, fingerprint: string): Promise<MachineResponse>;
/** Explicitly activate a seat for the given fingerprint. */
activate(key: string, fingerprint: string, opts?: {
hostname?: string;
platform?: string;
}): Promise<MachineResponse>;
/** Free a seat held by the given fingerprint. */
deactivate(key: string, fingerprint: string, reason?: string): Promise<MachineResponse>;
/** Start a purchase. Returns the checkout URL and invoice id. */
startPurchase(productSlug: string, opts?: StartPurchaseOptions): Promise<PurchaseSession>;
/**
* Redeem a `free_license` code: bypass BTCPay entirely and receive the
* signed license key directly. Throws if the code is unknown / disabled
* / expired / wrong product / not a free_license code, or if the cap
* has been reached.
*/
redeemFreeLicense(productSlug: string, code: string, opts?: RedeemFreeOptions): Promise<RedeemFreeResponse>;
/** Poll a purchase by its invoice id. */
pollPurchase(invoiceId: string): Promise<PollResponse>;
/**
* Convenience: open the checkout, poll until a license key is issued,
* then return it. Suitable for CLI usage or for an app UI that shows a
* spinner while the buyer pays.
*/
waitForLicense(invoiceId: string, options?: {
intervalMs?: number;
timeoutMs?: number;
}): Promise<string>;
private toValidateResponse;
private toMachineResponse;
private get;
private post;
private request;
}
/** Hash a raw fingerprint string to the 32-byte form embedded in keys. */
declare function hashFingerprint(raw: string): Uint8Array;
/** All errors thrown by this library inherit from `LicensingError`. */
declare class LicensingError extends Error {
/**
* Machine-readable reason code. Common values:
* `"bad_format"`, `"bad_encoding"`, `"bad_version"`, `"bad_signature"`,
* `"expired"`, `"server_error"`, `"http_error"`, `"other"`.
*/
readonly code: string;
constructor(code: string, message: string);
}
export { Client, FLAG_FINGERPRINT_BOUND, FLAG_TRIAL, KEY_PREFIX, KEY_VERSION, KEY_VERSION_V1, KEY_VERSION_V2, type LicenseKey, type LicensePayload, LicensingError, type MachineResponse, type PollResponse, PublicKey, type PurchaseSession, type RedeemFreeOptions, type RedeemFreeResponse, type StartPurchaseOptions, type ValidateOptions, type ValidateResponse, Verifier, type VerifyOk, hasEntitlement, hashFingerprint, isExpiredAt, parseLicenseKey };
+503
View File
@@ -0,0 +1,503 @@
// src/verify.ts
import * as ed from "@noble/ed25519";
import { sha512 } from "@noble/hashes/sha512";
// src/errors.ts
var LicensingError = class extends Error {
/**
* Machine-readable reason code. Common values:
* `"bad_format"`, `"bad_encoding"`, `"bad_version"`, `"bad_signature"`,
* `"expired"`, `"server_error"`, `"http_error"`, `"other"`.
*/
code;
constructor(code, message) {
super(message);
this.name = "LicensingError";
this.code = code;
}
};
// src/base32.ts
var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
var DECODE_TABLE = (() => {
const t = {};
for (let i = 0; i < ALPHABET.length; i++) t[ALPHABET[i]] = i;
return t;
})();
function decodeBase32NoPad(input) {
const up = input.toUpperCase();
const out = new Uint8Array(Math.floor(up.length * 5 / 8));
let bits = 0;
let value = 0;
let outPos = 0;
for (let i = 0; i < up.length; i++) {
const ch = up[i];
const v = DECODE_TABLE[ch];
if (v === void 0) {
throw new LicensingError("bad_encoding", `invalid base32 character '${ch}'`);
}
value = value << 5 | v;
bits += 5;
if (bits >= 8) {
bits -= 8;
out[outPos++] = value >> bits & 255;
}
}
return out.subarray(0, outPos);
}
// src/key.ts
var KEY_PREFIX = "LIC1";
var KEY_VERSION_V1 = 1;
var KEY_VERSION_V2 = 2;
var KEY_VERSION = KEY_VERSION_V2;
var FLAG_FINGERPRINT_BOUND = 1;
var FLAG_TRIAL = 2;
var PAYLOAD_V1_LEN = 74;
var PAYLOAD_V2_HEAD_LEN = 83;
var SIGNATURE_LEN = 64;
function isExpiredAt(payload, nowUnixSeconds) {
return payload.expiresAt !== 0 && nowUnixSeconds >= payload.expiresAt;
}
function hasEntitlement(payload, slug) {
return payload.entitlements.includes(slug);
}
function parseLicenseKey(raw) {
const trimmed = raw.trim();
const firstDash = trimmed.indexOf("-");
if (firstDash < 0) throw new LicensingError("bad_format", "key is missing prefix delimiter");
const prefix = trimmed.slice(0, firstDash);
if (prefix !== KEY_PREFIX) throw new LicensingError("bad_format", `unknown key prefix '${prefix}'`);
const body = trimmed.slice(firstDash + 1);
const lastDash = body.lastIndexOf("-");
if (lastDash < 0) throw new LicensingError("bad_format", "key is missing signature delimiter");
const payloadB32 = body.slice(0, lastDash);
const signatureB32 = body.slice(lastDash + 1);
const payloadBytes = decodeBase32NoPad(payloadB32);
const signature = decodeBase32NoPad(signatureB32);
if (signature.length !== SIGNATURE_LEN) {
throw new LicensingError(
"bad_format",
`signature is ${signature.length} bytes; expected ${SIGNATURE_LEN}`
);
}
if (payloadBytes.length < 1) {
throw new LicensingError("bad_format", "empty payload");
}
const version = payloadBytes[0];
let payload;
switch (version) {
case KEY_VERSION_V1:
payload = parseV1(payloadBytes);
break;
case KEY_VERSION_V2:
payload = parseV2(payloadBytes);
break;
default:
throw new LicensingError("bad_version", `unsupported key version ${version}`);
}
return {
payload,
signedBytes: payloadBytes,
signature
};
}
function parseV1(payloadBytes) {
if (payloadBytes.length !== PAYLOAD_V1_LEN) {
throw new LicensingError(
"bad_format",
`v1 payload is ${payloadBytes.length} bytes; expected ${PAYLOAD_V1_LEN}`
);
}
const flags = payloadBytes[1];
const productId = payloadBytes.slice(2, 18);
const licenseId = payloadBytes.slice(18, 34);
const issuedAt = readBigEndianI64(payloadBytes, 34);
const fingerprintHash = payloadBytes.slice(42, 74);
return {
version: KEY_VERSION_V1,
flags,
productId,
licenseId,
issuedAt,
expiresAt: 0,
fingerprintHash,
entitlements: [],
productUuid: uuidString(productId),
licenseUuid: uuidString(licenseId),
isFingerprintBound: (flags & FLAG_FINGERPRINT_BOUND) !== 0,
isTrial: (flags & FLAG_TRIAL) !== 0
};
}
function parseV2(payloadBytes) {
if (payloadBytes.length < PAYLOAD_V2_HEAD_LEN) {
throw new LicensingError(
"bad_format",
`v2 payload is ${payloadBytes.length} bytes; expected >= ${PAYLOAD_V2_HEAD_LEN}`
);
}
const flags = payloadBytes[1];
const productId = payloadBytes.slice(2, 18);
const licenseId = payloadBytes.slice(18, 34);
const issuedAt = readBigEndianI64(payloadBytes, 34);
const expiresAt = readBigEndianI64(payloadBytes, 42);
const fingerprintHash = payloadBytes.slice(50, 82);
const numEntitlements = payloadBytes[82];
const entitlements = [];
let cursor = PAYLOAD_V2_HEAD_LEN;
const decoder = new TextDecoder("utf-8", { fatal: true });
for (let i = 0; i < numEntitlements; i++) {
if (cursor >= payloadBytes.length) {
throw new LicensingError("bad_format", "truncated entitlement list");
}
const len = payloadBytes[cursor];
cursor += 1;
if (cursor + len > payloadBytes.length) {
throw new LicensingError("bad_format", "truncated entitlement");
}
try {
entitlements.push(decoder.decode(payloadBytes.slice(cursor, cursor + len)));
} catch {
throw new LicensingError("bad_format", "entitlement not utf-8");
}
cursor += len;
}
if (cursor !== payloadBytes.length) {
throw new LicensingError("bad_format", "trailing bytes in payload");
}
return {
version: KEY_VERSION_V2,
flags,
productId,
licenseId,
issuedAt,
expiresAt,
fingerprintHash,
entitlements,
productUuid: uuidString(productId),
licenseUuid: uuidString(licenseId),
isFingerprintBound: (flags & FLAG_FINGERPRINT_BOUND) !== 0,
isTrial: (flags & FLAG_TRIAL) !== 0
};
}
function readBigEndianI64(buf, offset) {
const view = new DataView(buf.buffer, buf.byteOffset + offset, 8);
const hi = view.getInt32(0, false);
const lo = view.getUint32(4, false);
return hi * 2 ** 32 + lo;
}
function uuidString(b) {
const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
// src/fingerprint.ts
import { sha256 } from "@noble/hashes/sha256";
function hashFingerprint(raw) {
return sha256(new TextEncoder().encode(raw));
}
// src/verify.ts
ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m));
var Verifier = class {
pubkey;
constructor(pubkey) {
this.pubkey = pubkey;
}
/** Verify a license key string. Throws on any failure. */
verify(keyStr) {
const key = parseLicenseKey(keyStr);
const ok = ed.verify(key.signature, key.signedBytes, this.pubkey.raw);
if (!ok) throw new LicensingError("bad_signature", "signature did not verify");
return {
payload: key.payload,
licenseId: key.payload.licenseUuid,
productId: key.payload.productUuid
};
}
/**
* Verify AND enforce that, if the key is fingerprint-bound, the given
* fingerprint matches. If the key is not bound, the fingerprint is
* ignored. Throws on any failure.
*/
verifyWithFingerprint(keyStr, fingerprint) {
const result = this.verify(keyStr);
if (result.payload.isFingerprintBound) {
const expected = hashFingerprint(fingerprint);
const stored = result.payload.fingerprintHash;
if (!equalBytes(expected, stored)) {
throw new LicensingError("bad_signature", "fingerprint does not match bound key");
}
}
return result;
}
/**
* Verify a key and additionally reject it with an `expired` error if
* `nowUnixSeconds` is at or past its `expiresAt`. Perpetual keys
* (`expiresAt === 0`) are accepted regardless of `nowUnixSeconds`. This is
* offline-only — no grace window logic; use `Client.validate` for that.
*/
verifyWithTime(keyStr, nowUnixSeconds) {
const result = this.verify(keyStr);
if (isExpiredAt(result.payload, nowUnixSeconds)) {
throw new LicensingError("expired", "license has expired");
}
return result;
}
};
function equalBytes(a, b) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff === 0;
}
// src/online.ts
var Client = class {
base;
constructor(baseUrl) {
this.base = baseUrl.replace(/\/+$/, "");
}
/** The normalized base URL this client is pinned to. */
baseUrl() {
return this.base;
}
/** Fetch the server's PEM-encoded public key. */
async fetchPubkeyPem() {
const data = await this.get("/v1/pubkey");
return data.public_key_pem;
}
/**
* Server-authoritative validation. Returns the full response including
* expiry / entitlements / seat fields introduced in v2.
*
* Two-argument form kept for call-site compatibility with earlier SDK
* versions; pass an options object for the full set of fields.
*/
async validate(key, productSlugOrOptions, fingerprint) {
const opts = typeof productSlugOrOptions === "string" ? { productSlug: productSlugOrOptions, fingerprint } : productSlugOrOptions ?? {};
const raw = await this.post("/v1/validate", {
key,
product_slug: opts.productSlug,
fingerprint: opts.fingerprint,
hostname: opts.hostname,
platform: opts.platform
});
return this.toValidateResponse(raw);
}
/** Lightweight heartbeat. Server updates `last_heartbeat_at`. */
async heartbeat(key, fingerprint) {
const raw = await this.post("/v1/machines/heartbeat", {
key,
fingerprint
});
return this.toMachineResponse(raw);
}
/** Explicitly activate a seat for the given fingerprint. */
async activate(key, fingerprint, opts = {}) {
const raw = await this.post("/v1/machines/activate", {
key,
fingerprint,
hostname: opts.hostname,
platform: opts.platform
});
return this.toMachineResponse(raw);
}
/** Free a seat held by the given fingerprint. */
async deactivate(key, fingerprint, reason) {
const raw = await this.post("/v1/machines/deactivate", {
key,
fingerprint,
reason
});
return this.toMachineResponse(raw);
}
/** Start a purchase. Returns the checkout URL and invoice id. */
async startPurchase(productSlug, opts = {}) {
const raw = await this.post("/v1/purchase", {
product: productSlug,
buyer_email: opts.buyerEmail,
buyer_note: opts.buyerNote,
redirect_url: opts.redirectUrl,
code: opts.code
});
return {
invoiceId: raw.invoice_id,
btcpayInvoiceId: raw.btcpay_invoice_id,
checkoutUrl: raw.checkout_url,
amountSats: raw.amount_sats,
pollUrl: raw.poll_url
};
}
/**
* Redeem a `free_license` code: bypass BTCPay entirely and receive the
* signed license key directly. Throws if the code is unknown / disabled
* / expired / wrong product / not a free_license code, or if the cap
* has been reached.
*/
async redeemFreeLicense(productSlug, code, opts = {}) {
const raw = await this.post("/v1/redeem", {
product: productSlug,
code,
buyer_email: opts.buyerEmail,
buyer_note: opts.buyerNote
});
return {
licenseId: raw.license_id,
licenseKey: raw.license_key,
invoiceId: raw.invoice_id,
redemptionId: raw.redemption_id
};
}
/** Poll a purchase by its invoice id. */
async pollPurchase(invoiceId) {
const raw = await this.get(
`/v1/purchase/${encodeURIComponent(invoiceId)}`
);
return {
invoiceId: raw.invoice_id,
status: raw.status,
productId: raw.product_id,
amountSats: raw.amount_sats,
licenseKey: raw.license_key ?? void 0,
licenseId: raw.license_id ?? void 0
};
}
/**
* Convenience: open the checkout, poll until a license key is issued,
* then return it. Suitable for CLI usage or for an app UI that shows a
* spinner while the buyer pays.
*/
async waitForLicense(invoiceId, options = {}) {
const interval = options.intervalMs ?? 5e3;
const deadline = options.timeoutMs ? Date.now() + options.timeoutMs : Infinity;
while (true) {
const poll = await this.pollPurchase(invoiceId);
if (poll.licenseKey) return poll.licenseKey;
if (poll.status === "expired" || poll.status === "invalid") {
throw new LicensingError("server_error", `invoice ended in status ${poll.status}`);
}
if (Date.now() > deadline) {
throw new LicensingError("server_error", "timed out waiting for license issuance");
}
await sleep(interval);
}
}
// --- internals ---
toValidateResponse(raw) {
const entitlements = Array.isArray(raw.entitlements) ? raw.entitlements.filter((x) => typeof x === "string") : void 0;
return {
ok: !!raw.ok,
reason: raw.reason,
licenseId: raw.license_id,
productId: raw.product_id,
productSlug: raw.product_slug,
issuedAt: raw.issued_at,
expiresAt: raw.expires_at,
graceUntil: raw.grace_until,
inGracePeriod: raw.in_grace_period,
isTrial: raw.is_trial,
entitlements,
status: raw.status,
machineId: raw.machine_id,
maxMachines: raw.max_machines
};
}
toMachineResponse(raw) {
return {
ok: !!raw.ok,
reason: raw.reason,
machineId: raw.machine_id,
activeCount: raw.active_count,
maxMachines: raw.max_machines
};
}
async get(path) {
return this.request(path, { method: "GET" });
}
async post(path, body) {
return this.request(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body)
});
}
async request(path, init) {
let resp;
try {
resp = await fetch(`${this.base}${path}`, init);
} catch (e) {
throw new LicensingError("http_error", e instanceof Error ? e.message : String(e));
}
const text = await resp.text();
if (!resp.ok) {
throw new LicensingError("server_error", `HTTP ${resp.status}: ${text}`);
}
try {
return JSON.parse(text);
} catch {
throw new LicensingError("server_error", `non-JSON response: ${text}`);
}
}
};
function sleep(ms) {
return new Promise((res) => setTimeout(res, ms));
}
// src/pubkey.ts
var PublicKey = class _PublicKey {
/** Raw 32-byte Ed25519 public key material. */
raw;
constructor(raw) {
if (raw.length !== 32) {
throw new LicensingError(
"bad_format",
`public key must be 32 bytes; got ${raw.length}`
);
}
this.raw = raw;
}
/** Parse a PEM blob as emitted by the service. */
static fromPem(pem) {
const stripped = pem.replace(/-----BEGIN [^-]+-----/g, "").replace(/-----END [^-]+-----/g, "").replace(/\s+/g, "");
if (!stripped) {
throw new LicensingError("bad_format", "empty PEM input");
}
const der = base64Decode(stripped);
if (der.length < 32) {
throw new LicensingError("bad_format", "PEM body too short to contain a public key");
}
const raw = der.slice(der.length - 32);
return new _PublicKey(raw);
}
/** Construct from raw bytes (no PEM envelope). */
static fromBytes(bytes) {
return new _PublicKey(bytes);
}
};
function base64Decode(b64) {
const nodeBuffer = globalThis.Buffer;
if (nodeBuffer) {
return new Uint8Array(nodeBuffer.from(b64, "base64"));
}
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
export {
Client,
FLAG_FINGERPRINT_BOUND,
FLAG_TRIAL,
KEY_PREFIX,
KEY_VERSION,
KEY_VERSION_V1,
KEY_VERSION_V2,
LicensingError,
PublicKey,
Verifier,
hasEntitlement,
hashFingerprint,
isExpiredAt,
parseLicenseKey
};
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@keysat/licensing-client",
"version": "0.1.0",
"description": "Client library for Keysat. Verifies signed license keys offline and wraps the HTTP API for purchase and revocation checks.",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": ["dist", "README.md", "LICENSE"],
"scripts": {},
"keywords": [
"bitcoin",
"licensing",
"btcpay",
"start9",
"ed25519"
],
"repository": "https://github.com/keysat-xyz/keysat-client-ts",
"license": "MIT",
"engines": { "node": ">=18" },
"dependencies": {
"@noble/ed25519": "^2.0.0",
"@noble/hashes": "^1.3.3"
},
"devDependencies": {}
}