Initial public commit

This commit is contained in:
Keysat
2026-05-07 10:38:23 -05:00
commit a35ddc73b3
18 changed files with 4092 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build output
dist/
build/
*.tsbuildinfo
# Env / secrets
.env
.env.local
.env.*.local
# Editor / OS cruft
.DS_Store
.idea/
.vscode/
*.swp
*.bak
*.tmp
# Coverage
coverage/
.nyc_output/
+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.
+15
View File
@@ -0,0 +1,15 @@
import { PublicKey, Verifier } from '@keysat/licensing-client'
// Paste the issuer's PEM into an env var, or (in production) embed via
// bundler (e.g. Vite `?raw` import, webpack raw-loader, etc.).
const pem = process.env.LICENSING_PUBKEY_PEM
if (!pem) throw new Error('set LICENSING_PUBKEY_PEM')
const keyStr = process.argv[2]
if (!keyStr) throw new Error('pass a license key as an argument')
const verifier = new Verifier(PublicKey.fromPem(pem))
const result = verifier.verify(keyStr)
console.log('license OK')
console.log(' licenseId =', result.licenseId)
console.log(' productId =', result.productId)
console.log(' issuedAt =', result.payload.issuedAt)
+18
View File
@@ -0,0 +1,18 @@
import { Client } from '@keysat/licensing-client'
const [baseUrl, slug] = process.argv.slice(2)
if (!baseUrl || !slug) {
throw new Error('usage: tsx online-validate.ts <base-url> <product-slug>')
}
const client = new Client(baseUrl)
const session = await client.startPurchase(slug)
console.log('open the checkout in your browser:')
console.log(' ' + session.checkoutUrl)
console.log('waiting for settlement...')
const key = await client.waitForLicense(session.invoiceId)
console.log('license issued:\n ' + key)
const v = await client.validate(key, slug)
console.log(`server says: ok=${v.ok} reason=${v.reason ?? '(none)'}`)
+2777
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"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": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
"test": "vitest run",
"prepublishOnly": "npm run build && npm test"
},
"keywords": [
"bitcoin",
"licensing",
"btcpay",
"start9",
"ed25519"
],
"repository": "https://github.com/keysat-xyz/keysat",
"license": "MIT",
"engines": { "node": ">=18" },
"dependencies": {
"@noble/ed25519": "^2.0.0",
"@noble/hashes": "^1.3.3"
},
"devDependencies": {
"typescript": "^5.3.0",
"tsup": "^8.0.0",
"vitest": "^1.0.0"
}
}
+36
View File
@@ -0,0 +1,36 @@
/**
* RFC 4648 base32 (no padding), uppercase alphabet. Matches the `BASE32_NOPAD`
* encoder used by the Rust service. Implemented inline so we don't pull in a
* 300-line dependency.
*/
import { LicensingError } from './errors.js'
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
const DECODE_TABLE: Record<string, number> = (() => {
const t: Record<string, number> = {}
for (let i = 0; i < ALPHABET.length; i++) t[ALPHABET[i]!] = i
return t
})()
export function decodeBase32NoPad(input: string): Uint8Array {
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 === undefined) {
throw new LicensingError('bad_encoding', `invalid base32 character '${ch}'`)
}
value = (value << 5) | v
bits += 5
if (bits >= 8) {
bits -= 8
out[outPos++] = (value >> bits) & 0xff
}
}
return out.subarray(0, outPos)
}
+15
View File
@@ -0,0 +1,15 @@
/** All errors thrown by this library inherit from `LicensingError`. */
export 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) {
super(message)
this.name = 'LicensingError'
this.code = code
}
}
+7
View File
@@ -0,0 +1,7 @@
/** Hash a raw fingerprint string to the 32-byte form embedded in keys. */
import { sha256 } from '@noble/hashes/sha256'
export function hashFingerprint(raw: string): Uint8Array {
return sha256(new TextEncoder().encode(raw))
}
+35
View File
@@ -0,0 +1,35 @@
/**
* @keysat/licensing-client
*
* Client for Keysat. Works in both Node.js (>= 18) and modern
* browsers with no polyfills thanks to the `@noble/*` primitives.
*/
export { Verifier, type VerifyOk } from './verify.js'
export {
Client,
type ValidateResponse,
type ValidateOptions,
type MachineResponse,
type PurchaseSession,
type PollResponse,
type StartPurchaseOptions,
type RedeemFreeOptions,
type RedeemFreeResponse,
} from './online.js'
export {
parseLicenseKey,
isExpiredAt,
hasEntitlement,
type LicenseKey,
type LicensePayload,
KEY_PREFIX,
KEY_VERSION,
KEY_VERSION_V1,
KEY_VERSION_V2,
FLAG_FINGERPRINT_BOUND,
FLAG_TRIAL,
} from './key.js'
export { PublicKey } from './pubkey.js'
export { hashFingerprint } from './fingerprint.js'
export { LicensingError } from './errors.js'
+253
View File
@@ -0,0 +1,253 @@
/**
* 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.
*/
import { decodeBase32NoPad } from './base32.js'
import { LicensingError } from './errors.js'
export const KEY_PREFIX = 'LIC1'
/** v1 format identifier. */
export const KEY_VERSION_V1 = 1
/** v2 format identifier. */
export const KEY_VERSION_V2 = 2
/** Highest format version this client understands. */
export const KEY_VERSION = KEY_VERSION_V2
/** Set when the key is bound to a specific machine fingerprint hash. */
export const FLAG_FINGERPRINT_BOUND = 0b0000_0001
/** Set on trial keys. */
export const FLAG_TRIAL = 0b0000_0010
const PAYLOAD_V1_LEN = 74
const PAYLOAD_V2_HEAD_LEN = 83
const SIGNATURE_LEN = 64
/** Decoded fields of the signed payload. */
export 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. */
export 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`. */
export function isExpiredAt(payload: LicensePayload, nowUnixSeconds: number): boolean {
return payload.expiresAt !== 0 && nowUnixSeconds >= payload.expiresAt
}
/** True if the license grants the given entitlement slug. */
export function hasEntitlement(payload: LicensePayload, slug: string): boolean {
return payload.entitlements.includes(slug)
}
/** Parse a `LIC1-...-...` string. Does NOT verify. */
export function parseLicenseKey(raw: string): LicenseKey {
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: LicensePayload
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: Uint8Array): LicensePayload {
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: Uint8Array): LicensePayload {
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: string[] = []
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: Uint8Array, offset: number): number {
// JavaScript numbers lose precision beyond 2^53 — fine for Unix-second
// timestamps through the year 2^53 ≈ 285 million AD.
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: Uint8Array): string {
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)}`
}
+345
View File
@@ -0,0 +1,345 @@
/**
* 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.
*/
import { LicensingError } from './errors.js'
export 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
}
export 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
}
export interface MachineResponse {
ok: boolean
reason?: string
machineId?: string
activeCount?: number
maxMachines?: number
}
export 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
}
export 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
}
export 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
}
export interface RedeemFreeOptions {
/** Optional email recorded on the synthetic invoice + license. */
buyerEmail?: string
/** Optional buyer note. */
buyerNote?: string
}
export 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. */
export class Client {
private base: string
constructor(baseUrl: string) {
this.base = baseUrl.replace(/\/+$/, '')
}
/** The normalized base URL this client is pinned to. */
baseUrl(): string {
return this.base
}
/** Fetch the server's PEM-encoded public key. */
async fetchPubkeyPem(): Promise<string> {
const data = await this.get<{ public_key_pem: string }>('/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: string,
productSlugOrOptions?: string | ValidateOptions,
fingerprint?: string,
): Promise<ValidateResponse> {
const opts: ValidateOptions =
typeof productSlugOrOptions === 'string'
? { productSlug: productSlugOrOptions, fingerprint }
: productSlugOrOptions ?? {}
const raw = await this.post<Record<string, unknown>>('/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: string, fingerprint: string): Promise<MachineResponse> {
const raw = await this.post<Record<string, unknown>>('/v1/machines/heartbeat', {
key,
fingerprint,
})
return this.toMachineResponse(raw)
}
/** Explicitly activate a seat for the given fingerprint. */
async activate(
key: string,
fingerprint: string,
opts: { hostname?: string; platform?: string } = {},
): Promise<MachineResponse> {
const raw = await this.post<Record<string, unknown>>('/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: string,
fingerprint: string,
reason?: string,
): Promise<MachineResponse> {
const raw = await this.post<Record<string, unknown>>('/v1/machines/deactivate', {
key,
fingerprint,
reason,
})
return this.toMachineResponse(raw)
}
/** Start a purchase. Returns the checkout URL and invoice id. */
async startPurchase(
productSlug: string,
opts: StartPurchaseOptions = {},
): Promise<PurchaseSession> {
const raw = await this.post<Record<string, unknown>>('/v1/purchase', {
product: productSlug,
buyer_email: opts.buyerEmail,
buyer_note: opts.buyerNote,
redirect_url: opts.redirectUrl,
code: opts.code,
})
return {
invoiceId: raw.invoice_id as string,
btcpayInvoiceId: raw.btcpay_invoice_id as string,
checkoutUrl: raw.checkout_url as string,
amountSats: raw.amount_sats as number,
pollUrl: raw.poll_url as string,
}
}
/**
* 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: string,
code: string,
opts: RedeemFreeOptions = {},
): Promise<RedeemFreeResponse> {
const raw = await this.post<Record<string, unknown>>('/v1/redeem', {
product: productSlug,
code,
buyer_email: opts.buyerEmail,
buyer_note: opts.buyerNote,
})
return {
licenseId: raw.license_id as string,
licenseKey: raw.license_key as string,
invoiceId: raw.invoice_id as string,
redemptionId: raw.redemption_id as string,
}
}
/** Poll a purchase by its invoice id. */
async pollPurchase(invoiceId: string): Promise<PollResponse> {
const raw = await this.get<Record<string, unknown>>(
`/v1/purchase/${encodeURIComponent(invoiceId)}`,
)
return {
invoiceId: raw.invoice_id as string,
status: raw.status as string,
productId: raw.product_id as string,
amountSats: raw.amount_sats as number,
licenseKey: (raw.license_key as string | null) ?? undefined,
licenseId: (raw.license_id as string | null) ?? undefined,
}
}
/**
* 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: string,
options: { intervalMs?: number; timeoutMs?: number } = {},
): Promise<string> {
const interval = options.intervalMs ?? 5000
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 ---
private toValidateResponse(raw: Record<string, unknown>): ValidateResponse {
const entitlements = Array.isArray(raw.entitlements)
? (raw.entitlements as unknown[]).filter((x): x is string => typeof x === 'string')
: undefined
return {
ok: !!raw.ok,
reason: raw.reason as string | undefined,
licenseId: raw.license_id as string | undefined,
productId: raw.product_id as string | undefined,
productSlug: raw.product_slug as string | undefined,
issuedAt: raw.issued_at as string | undefined,
expiresAt: raw.expires_at as string | undefined,
graceUntil: raw.grace_until as string | undefined,
inGracePeriod: raw.in_grace_period as boolean | undefined,
isTrial: raw.is_trial as boolean | undefined,
entitlements,
status: raw.status as string | undefined,
machineId: raw.machine_id as string | undefined,
maxMachines: raw.max_machines as number | undefined,
}
}
private toMachineResponse(raw: Record<string, unknown>): MachineResponse {
return {
ok: !!raw.ok,
reason: raw.reason as string | undefined,
machineId: raw.machine_id as string | undefined,
activeCount: raw.active_count as number | undefined,
maxMachines: raw.max_machines as number | undefined,
}
}
private async get<T>(path: string): Promise<T> {
return this.request<T>(path, { method: 'GET' })
}
private async post<T>(path: string, body: unknown): Promise<T> {
return this.request<T>(path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
}
private async request<T>(path: string, init: RequestInit): Promise<T> {
let resp: Response
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) as T
} catch {
throw new LicensingError('server_error', `non-JSON response: ${text}`)
}
}
}
function sleep(ms: number): Promise<void> {
return new Promise((res) => setTimeout(res, ms))
}
+61
View File
@@ -0,0 +1,61 @@
/**
* 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`).
*/
import { LicensingError } from './errors.js'
/** Parsed Ed25519 public key, ready for signature verification. */
export class PublicKey {
/** Raw 32-byte Ed25519 public key material. */
readonly raw: Uint8Array
constructor(raw: Uint8Array) {
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: string): PublicKey {
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)
// Ed25519 SubjectPublicKeyInfo: 12 bytes of DER header + 32 bytes of key.
// We don't bother to fully parse the ASN.1 — we just assert total length
// of 44 bytes and slice the tail.
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: Uint8Array): PublicKey {
return new PublicKey(bytes)
}
}
function base64Decode(b64: string): Uint8Array {
// Prefer Node's Buffer if present (fastest path); fall back to atob.
const nodeBuffer = (globalThis as { Buffer?: { from: (s: string, enc: string) => Uint8Array } })
.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
}
+80
View File
@@ -0,0 +1,80 @@
/** Offline Ed25519 signature verification. */
import * as ed from '@noble/ed25519'
import { sha512 } from '@noble/hashes/sha512'
import { LicensingError } from './errors.js'
import { parseLicenseKey, isExpiredAt, type LicensePayload } from './key.js'
import { PublicKey } from './pubkey.js'
import { hashFingerprint } from './fingerprint.js'
// `@noble/ed25519` requires us to plug in a hash function. This is a one-time
// init on module load; downstream callers don't need to care.
ed.etc.sha512Sync = (...m: Uint8Array[]) => sha512(ed.etc.concatBytes(...m))
export 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. */
export class Verifier {
private pubkey: PublicKey
constructor(pubkey: PublicKey) {
this.pubkey = pubkey
}
/** Verify a license key string. Throws on any failure. */
verify(keyStr: string): VerifyOk {
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: string, fingerprint: string): VerifyOk {
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: string, nowUnixSeconds: number): VerifyOk {
const result = this.verify(keyStr)
if (isExpiredAt(result.payload, nowUnixSeconds)) {
throw new LicensingError('expired', 'license has expired')
}
return result
}
}
function equalBytes(a: Uint8Array, b: Uint8Array): boolean {
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
}
+218
View File
@@ -0,0 +1,218 @@
/**
* Cross-check the TypeScript SDK against the canonical wire-format
* test vectors at ./vector.json (mirrored from
* <repo>/tests/crosscheck/vector.json).
*
* These are the same vectors exercised by the Python and Rust SDKs.
* Any new SDK must pass every fixture in vector.json — that's how we
* guarantee wire-format compatibility across languages.
*
* Run with: `npm test`.
*/
import { describe, it, expect, beforeAll } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import {
Verifier,
PublicKey,
parseLicenseKey,
isExpiredAt,
hasEntitlement,
hashFingerprint,
LicensingError,
KEY_VERSION_V1,
KEY_VERSION_V2,
} from '../src/index.js'
interface Fixture {
licenseKey: string
expected: {
version: number
productUuid: string
licenseUuid: string
issuedAt: number
expiresAt: number
flags: number
isFingerprintBound: boolean
isTrial: boolean
entitlements: string[]
fingerprintRaw: string | null
fingerprintHashHex: string
}
}
interface Vectors {
publicKeyPem: string
v1: Fixture
v2: Fixture
v2_perpetual_unbound?: Fixture
}
const vectors: Vectors = JSON.parse(
readFileSync(join(__dirname, 'vector.json'), 'utf8'),
)
const verifier = new Verifier(PublicKey.fromPem(vectors.publicKeyPem))
function bytesToHex(b: Uint8Array): string {
return Array.from(b)
.map((x) => x.toString(16).padStart(2, '0'))
.join('')
}
// ----------------------------------------------------------------------
// v1 fixture: legacy fixed-74 layout, fingerprint-bound, no expiry,
// no entitlements.
// ----------------------------------------------------------------------
describe('v1 fixture', () => {
it('parses', () => {
const parsed = parseLicenseKey(vectors.v1.licenseKey)
const exp = vectors.v1.expected
expect(parsed.payload.version).toBe(exp.version)
expect(parsed.payload.version).toBe(KEY_VERSION_V1)
expect(parsed.payload.productUuid).toBe(exp.productUuid)
expect(parsed.payload.licenseUuid).toBe(exp.licenseUuid)
expect(parsed.payload.issuedAt).toBe(exp.issuedAt)
expect(parsed.payload.expiresAt).toBe(exp.expiresAt)
expect(parsed.payload.flags).toBe(exp.flags)
expect(parsed.payload.isFingerprintBound).toBe(exp.isFingerprintBound)
expect(parsed.payload.isTrial).toBe(exp.isTrial)
expect(parsed.payload.entitlements).toEqual(exp.entitlements)
expect(bytesToHex(parsed.payload.fingerprintHash)).toBe(
exp.fingerprintHashHex,
)
})
it('verifies signature', () => {
const ok = verifier.verify(vectors.v1.licenseKey)
expect(ok.productId).toBe(vectors.v1.expected.productUuid)
expect(ok.licenseId).toBe(vectors.v1.expected.licenseUuid)
})
it('detects tampering', () => {
const key = vectors.v1.licenseKey
const dashIdx = key.indexOf('-')
expect(dashIdx).toBeGreaterThan(0)
const payloadStart = dashIdx + 1
const swapChar = key[payloadStart] !== 'B' ? 'B' : 'C'
const tampered =
key.substring(0, payloadStart) +
swapChar +
key.substring(payloadStart + 1)
expect(() => verifier.verify(tampered)).toThrow(LicensingError)
})
})
// ----------------------------------------------------------------------
// v2 fixture: trial, fingerprint-bound, explicit expiry, two entitlements.
// Stresses the variable-length tail parser.
// ----------------------------------------------------------------------
describe('v2 fixture', () => {
it('parses', () => {
const parsed = parseLicenseKey(vectors.v2.licenseKey)
const exp = vectors.v2.expected
expect(parsed.payload.version).toBe(exp.version)
expect(parsed.payload.version).toBe(KEY_VERSION_V2)
expect(parsed.payload.productUuid).toBe(exp.productUuid)
expect(parsed.payload.licenseUuid).toBe(exp.licenseUuid)
expect(parsed.payload.issuedAt).toBe(exp.issuedAt)
expect(parsed.payload.expiresAt).toBe(exp.expiresAt)
expect(parsed.payload.flags).toBe(exp.flags)
expect(parsed.payload.isFingerprintBound).toBe(exp.isFingerprintBound)
expect(parsed.payload.isTrial).toBe(exp.isTrial)
expect(parsed.payload.entitlements).toEqual(exp.entitlements)
})
it('verifies signature', () => {
const ok = verifier.verify(vectors.v2.licenseKey)
expect(ok.productId).toBe(vectors.v2.expected.productUuid)
})
it('expiry boundary is exact', () => {
const parsed = parseLicenseKey(vectors.v2.licenseKey)
const expiresAt = parsed.payload.expiresAt
expect(isExpiredAt(parsed.payload, expiresAt)).toBe(true)
expect(isExpiredAt(parsed.payload, expiresAt - 1)).toBe(false)
})
it('reports configured entitlements', () => {
const parsed = parseLicenseKey(vectors.v2.licenseKey)
for (const slug of vectors.v2.expected.entitlements) {
expect(hasEntitlement(parsed.payload, slug)).toBe(true)
}
expect(
hasEntitlement(parsed.payload, 'definitely-not-a-real-entitlement'),
).toBe(false)
})
})
// ----------------------------------------------------------------------
// v2_perpetual_unbound — common case for paid purchase: v2, no expiry,
// no fingerprint binding, no entitlements.
// ----------------------------------------------------------------------
describe('v2_perpetual_unbound fixture', () => {
it('parses if present', () => {
if (!vectors.v2_perpetual_unbound) return
const parsed = parseLicenseKey(vectors.v2_perpetual_unbound.licenseKey)
expect(parsed.payload.version).toBe(2)
expect(parsed.payload.expiresAt).toBe(0)
expect(parsed.payload.isFingerprintBound).toBe(false)
})
it('verifies if present', () => {
if (!vectors.v2_perpetual_unbound) return
const ok = verifier.verify(vectors.v2_perpetual_unbound.licenseKey)
expect(ok.productId).toBe(
vectors.v2_perpetual_unbound.expected.productUuid,
)
})
it('never reports expired (perpetual)', () => {
if (!vectors.v2_perpetual_unbound) return
const parsed = parseLicenseKey(vectors.v2_perpetual_unbound.licenseKey)
expect(isExpiredAt(parsed.payload, 9_999_999_999)).toBe(false)
})
})
// ----------------------------------------------------------------------
// Cross-language fingerprint-hash compatibility.
// ----------------------------------------------------------------------
describe('hashFingerprint', () => {
it('matches Python stdlib SHA-256 hex output', () => {
const hashed = bytesToHex(hashFingerprint('hello'))
// Python: hashlib.sha256(b"hello").hexdigest()
expect(hashed).toBe(
'2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824',
)
})
it('matches the v1 fixture fingerprint hash', () => {
const raw = vectors.v1.expected.fingerprintRaw
if (raw == null) return
const hashed = bytesToHex(hashFingerprint(raw))
expect(hashed).toBe(vectors.v1.expected.fingerprintHashHex)
})
})
// ----------------------------------------------------------------------
// Negative cases.
// ----------------------------------------------------------------------
describe('negative cases', () => {
it('rejects a too-short key', () => {
expect(() => parseLicenseKey('notakey')).toThrow(LicensingError)
})
it('rejects a wrong prefix', () => {
expect(() => parseLicenseKey('LIC9-AAAA-BBBB')).toThrow(LicensingError)
})
it('rejects an empty string', () => {
expect(() => parseLicenseKey('')).toThrow(LicensingError)
})
})
+54
View File
@@ -0,0 +1,54 @@
{
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=\n-----END PUBLIC KEY-----\n",
"v1": {
"licenseKey": "LIC1-AEAW6RVE6YGS6SRIW2VD5D57N4UPAEIRCEISEIRTGNCEIVKVKVKVKVIAAAAAAZKT6EANGRDB75RRODPGGO22UJISZYXBLLASBMODEWWNVWTHYAXFSS5DWPI-FV56FI7ZTB5GIFQHIPQ35QVVE5AO5FQGVQS45UJ5F632MLXS7VHMHYVLZWGE64FJOEXD2PVIFNE5XGRMTNOUOVEKDTW736743W25MAY",
"expected": {
"version": 1,
"productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0",
"licenseUuid": "11111111-2222-3333-4444-555555555555",
"issuedAt": 1700000000,
"expiresAt": 0,
"flags": 1,
"isFingerprintBound": true,
"isTrial": false,
"entitlements": [],
"fingerprintRaw": "test-machine-fingerprint",
"fingerprintHashHex": "d34461ff63170de633b5aa2512ce2e15ac120b1c325acdada67c02e594ba3b3d"
}
},
"v2": {
"licenseKey": "LIC1-AIBW6RVE6YGS6SRIW2VD5D57N4UPBKVKVKVLXO6MZTO533XO53XO53QAAAAAAZKT6EAAAAAAABYT7MYA2NCGD73DC4G6MM5VVISRFTROCWWBECY4GJNM3LNGPQBOLFF2HM6QEA3QOJXQY3LVNR2GSLLEMV3GSY3F-QPSJIDYL6Y5TFCKXQ2SN43EDJIZIRJZCEROM2I4MJHODT6KO4KDPW6AJ3HMYJERYPD34CF2Z46PXPYFKSRZS7BDZKVKWE57UBJSTEBI",
"expected": {
"version": 2,
"productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0",
"licenseUuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"issuedAt": 1700000000,
"expiresAt": 1900000000,
"flags": 3,
"isFingerprintBound": true,
"isTrial": true,
"entitlements": [
"pro",
"multi-device"
],
"fingerprintRaw": "test-machine-fingerprint",
"fingerprintHashHex": "d34461ff63170de633b5aa2512ce2e15ac120b1c325acdada67c02e594ba3b3d"
}
},
"v2_perpetual_unbound": {
"licenseKey": "LIC1-AIAG6RVE6YGS6SRIW2VD5D57N4UPBKVKVKVLXO6MZTO533XO53XO53QAAAAAAZKT6EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-E4IT25ES5NBBQAXVAMZPLBDB5P2ILZL4RGKYUWEWLME5ZVGM7HGBG5CP3XHWBQ5FCYPEC6YGKBHCTQ7M7RZP7OR7NAYAMNAJAWW4QDQ",
"expected": {
"version": 2,
"productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0",
"licenseUuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"issuedAt": 1700000000,
"expiresAt": 0,
"flags": 0,
"isFingerprintBound": false,
"isTrial": false,
"entitlements": [],
"fingerprintRaw": null,
"fingerprintHashHex": "0000000000000000000000000000000000000000000000000000000000000000"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ES2022", "DOM"],
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "examples"]
}