Initial public commit
This commit is contained in:
+27
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
@@ -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)'}`)
|
||||||
Generated
+2777
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user