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
+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
}