134 lines
4.6 KiB
TypeScript
134 lines
4.6 KiB
TypeScript
// Action: "Activate license" — buyer pastes a license key, we verify it (both
|
|
// offline against the embedded issuer public key, and online against the
|
|
// licensing server if reachable) and persist it in the package store.
|
|
//
|
|
// After this runs successfully, your main process can read
|
|
// `store.license_key` and inject it into your app.
|
|
|
|
import { sdk } from '../sdk'
|
|
import { Verifier, PublicKey, Client } from '@keysat/licensing-client'
|
|
import {
|
|
LICENSING_BASE_URL,
|
|
LICENSING_BASE_URL_TOR,
|
|
PRODUCT_SLUG,
|
|
ISSUER_PUBKEY_PEM,
|
|
PRODUCT_DISPLAY_NAME,
|
|
} from '../licensing/config'
|
|
|
|
const input = sdk.InputSpec.of({
|
|
license_key: {
|
|
type: 'text',
|
|
name: 'License key',
|
|
description:
|
|
`Paste the license key you received after purchasing ${PRODUCT_DISPLAY_NAME}. ` +
|
|
`It looks like "LIC1-...-...". Case-sensitive; no surrounding whitespace.`,
|
|
required: true,
|
|
default: null,
|
|
masked: false,
|
|
},
|
|
})
|
|
|
|
export const activateLicense = sdk.Action.withInput(
|
|
'activate-license',
|
|
async ({ effects }) => ({
|
|
name: 'Activate license',
|
|
description:
|
|
`Verify and save a license key for ${PRODUCT_DISPLAY_NAME}. ` +
|
|
`The key is verified cryptographically on-device, then cross-checked ` +
|
|
`with the issuing server if it is reachable.`,
|
|
warning: null,
|
|
allowedStatuses: 'any',
|
|
group: 'License',
|
|
visibility: 'enabled',
|
|
}),
|
|
input,
|
|
async ({ effects, input: form }) => {
|
|
const key = (form.license_key ?? '').trim()
|
|
if (!key) throw new Error('License key is empty.')
|
|
|
|
// -- 1. Offline signature check (always runs).
|
|
let offline
|
|
try {
|
|
const verifier = new Verifier(PublicKey.fromPem(ISSUER_PUBKEY_PEM))
|
|
offline = verifier.verify(key)
|
|
} catch (e) {
|
|
throw new Error(
|
|
`This key's signature did not verify against the embedded issuer ` +
|
|
`public key. It is either corrupt, truncated, or not issued by ` +
|
|
`${PRODUCT_DISPLAY_NAME}.\n\nDetails: ${errMsg(e)}`,
|
|
)
|
|
}
|
|
|
|
// -- 2. Online check (best-effort). Catches revocation and fingerprint
|
|
// mismatches from a previously-seen device. Failure here is NOT fatal
|
|
// — the buyer may be offline. We record the last-known status.
|
|
const fingerprint = await getMachineFingerprint(effects)
|
|
const onlineStatus = await tryOnlineValidate(key, fingerprint)
|
|
if (onlineStatus.reachable && !onlineStatus.ok) {
|
|
throw new Error(
|
|
`The licensing server rejected this key: ${onlineStatus.reason ?? 'unknown reason'}.\n\n` +
|
|
`Most common causes: key was revoked; key is bound to a different ` +
|
|
`machine; wrong product.`,
|
|
)
|
|
}
|
|
|
|
// -- 3. Persist.
|
|
await sdk.store.getOwn(effects, sdk.StorePath).merge({
|
|
license_key: key,
|
|
license_activated_at: new Date().toISOString(),
|
|
license_last_status: onlineStatus.reachable
|
|
? 'valid (online-checked)'
|
|
: 'valid (offline-only)',
|
|
license_pending_invoice_id: null,
|
|
})
|
|
|
|
return {
|
|
message:
|
|
`License activated.\n\n` +
|
|
`Product: ${offline.productId}\n` +
|
|
`License id: ${offline.licenseId}\n` +
|
|
`Status: ${onlineStatus.reachable ? 'valid (confirmed with issuing server)' : 'valid (signature only — server not reachable)'}\n\n` +
|
|
`You may need to restart ${PRODUCT_DISPLAY_NAME} for the change to take effect.`,
|
|
}
|
|
},
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function tryOnlineValidate(
|
|
key: string,
|
|
fingerprint: string,
|
|
): Promise<{ reachable: boolean; ok?: boolean; reason?: string }> {
|
|
const urls = [LICENSING_BASE_URL, LICENSING_BASE_URL_TOR].filter(Boolean) as string[]
|
|
for (const base of urls) {
|
|
try {
|
|
const client = new Client(base)
|
|
const r = await client.validate(key, PRODUCT_SLUG, fingerprint)
|
|
return { reachable: true, ok: r.ok, reason: r.reason }
|
|
} catch (_) {
|
|
// try next URL
|
|
}
|
|
}
|
|
return { reachable: false }
|
|
}
|
|
|
|
/**
|
|
* Derive a stable per-machine fingerprint. Start9 packages have a host UUID;
|
|
* if that's unavailable we fall back to the operating system's machine-id.
|
|
* The fingerprint is hashed by the SDK before being sent to the server.
|
|
*/
|
|
async function getMachineFingerprint(effects: unknown): Promise<string> {
|
|
// Prefer: Start9-provided device identifier if available.
|
|
// Fallback: /etc/machine-id on the host.
|
|
try {
|
|
const { readFile } = await import('fs/promises')
|
|
const id = (await readFile('/etc/machine-id', 'utf8')).trim()
|
|
if (id) return `machine-id:${id}`
|
|
} catch (_) {}
|
|
return 'fingerprint:unknown'
|
|
}
|
|
|
|
function errMsg(e: unknown): string {
|
|
return e instanceof Error ? e.message : String(e)
|
|
}
|