/** * Verifying a licence. * * Vendored from Grant, the tool that issues them, and deliberately identical * to the copy there: nothing imported but `node:crypto`, no dependencies, no * network, no filesystem. Wellformed embeds its own public key, reads the * token out of its configuration, and calls `verifyLicence`. * * Do not grow this file. Anything Wellformed wants on top of it belongs in * `index.ts` beside it, so that this one stays a copy that can be replaced * wholesale when Grant's changes. */ import { createHash, createPublicKey, verify as verifySignature, type KeyObject } from 'node:crypto'; /** * Version tag on every token. * * It is the first thing read and the first thing to change if the format ever * does, so that an old product meeting a new token says "I do not know this * kind of licence" instead of guessing at the bytes. */ export const TOKEN_PREFIX = 'grant1'; /** A licence, as it was signed. */ export interface Licence { /** Slug of the software this is a licence for, e.g. `wellformed`. */ product: string; /** Reference as it appears on the deed, e.g. `WF-2026-001`. */ ref: string; /** Legal name of the party it was granted to. */ to: string; /** Date of the grant, `YYYY-MM-DD`. */ from: string; /** `perpetual`, or the `YYYY-MM-DD` on which it ends. */ term: string; /** Fingerprint of the key that signed it. */ keyId: string; /** Anything the grant added on top. Free-form, and product-specific. */ note?: string; } /** Why a token was not accepted. */ export type LicenceFault = | 'missing' | 'malformed' | 'unknown-format' | 'bad-signature' | 'wrong-product' | 'expired'; export class LicenceError extends Error { readonly fault: LicenceFault; constructor(fault: LicenceFault, message: string) { super(message); this.name = 'LicenceError'; this.fault = fault; } } export interface VerifyOptions { /** * Refuse a licence issued for other software. * * Worth setting always. Without it a licence for one product is a licence * for every product signed by the same key, which is exactly the mistake a * per-product key pair exists to prevent. */ product?: string; /** Overridable only so that tests can sit at a date. */ now?: Date; } /** * Check a token against one or more public keys and return what it says. * * Throws `LicenceError` and returns nothing on failure — there is no partly * good licence, and a caller that wants to keep running past a bad one should * have to say so in a `catch`. * * Several keys may be passed so that a key can be rotated without invalidating * what was signed with the old one: keep the retired public key in the list * until the licences it signed have been reissued. */ export function verifyLicence( token: string | undefined | null, keys: PublicKeyInput | readonly PublicKeyInput[], options: VerifyOptions = {}, ): Licence { const raw = (token ?? '').trim(); if (raw === '') throw new LicenceError('missing', 'No licence is set.'); const parts = raw.split('.'); if (parts.length !== 3) { throw new LicenceError('malformed', 'A licence is three parts separated by dots.'); } const [prefix, payloadSegment, signatureSegment] = parts as [string, string, string]; if (prefix !== TOKEN_PREFIX) { throw new LicenceError('unknown-format', `Licence format ${prefix} is not one this version knows.`); } // The signature covers the payload segment exactly as it was written, not a // re-serialisation of the object inside it. That way nothing depends on two // JSON encoders agreeing about key order or whitespace. const signed = Buffer.from(payloadSegment, 'ascii'); const signature = Buffer.from(signatureSegment, 'base64url'); const candidates = Array.isArray(keys) ? keys : [keys as PublicKeyInput]; const accepted = candidates.some((key) => { try { return verifySignature(null, signed, publicKeyFrom(key), signature); } catch { // A key that cannot be read is a key that does not accept this licence. return false; } }); if (!accepted) { throw new LicenceError('bad-signature', 'This licence was not signed by the key this software trusts.'); } const licence = decodePayload(payloadSegment); if (options.product !== undefined && licence.product !== options.product) { throw new LicenceError( 'wrong-product', `This is a licence for ${licence.product}, not for ${options.product}.`, ); } const expiry = expiryOf(licence); if (expiry !== null && (options.now ?? new Date()) > expiry) { throw new LicenceError('expired', `This licence ran to ${licence.term}.`); } return licence; } /** * Read a token without checking the signature. * * For showing a human what a token claims — in a CLI, in a support mail — and * for nothing else. Anyone can write anything here. Never branch on it. */ export function decodeUnverified(token: string): Licence { const segment = token.trim().split('.')[1]; if (segment === undefined) throw new LicenceError('malformed', 'A licence is three parts separated by dots.'); return decodePayload(segment); } /** The moment a licence stops covering anything, or `null` if it is perpetual. */ export function expiryOf(licence: Licence): Date | null { if (licence.term === 'perpetual') return null; const end = new Date(`${licence.term}T23:59:59.999Z`); if (Number.isNaN(end.getTime())) { throw new LicenceError('malformed', `Licence term ${licence.term} is not a date.`); } return end; } /** * One line naming who holds this, for a log or a settings screen. * * `Acme B.V. · WF-2026-001 · since 3 March 2026 · perpetual` */ export function describeLicence(licence: Licence): string { const term = licence.term === 'perpetual' ? 'perpetual' : `until ${formatDate(licence.term)}`; return `${licence.to} · ${licence.ref} · since ${formatDate(licence.from)} · ${term}`; } /** `2026-03-03` as `3 March 2026`, for anything a person reads. */ export function formatDate(iso: string): string { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); if (match === null) return iso; const [, year, month, day] = match as unknown as [string, string, string, string]; const name = MONTHS[Number(month) - 1] ?? month; return `${Number(day)} ${name} ${year}`; } const MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ] as const; /** * A public key, in whichever of the three shapes is at hand. * * PEM is what `grant keygen` writes and what a product embeds. The bare * base64 form is there because a 44-character string survives being pasted * into a config file, a Dockerfile or a systemd unit, and a PEM block does * not. */ export type PublicKeyInput = string | Buffer | KeyObject; /** Ed25519 SPKI is a fixed 12-byte header and then the 32-byte key. */ const SPKI_ED25519_HEADER = Buffer.from('302a300506032b6570032100', 'hex'); /** Turn any of the accepted shapes into a key `node:crypto` will use. */ export function publicKeyFrom(value: PublicKeyInput): KeyObject { if (typeof value === 'object' && !Buffer.isBuffer(value)) return value; const bytes = Buffer.isBuffer(value) ? value : null; const text = bytes === null ? String(value).trim() : ''; if (text.includes('BEGIN')) { return createPublicKey(text); } const raw = bytes ?? Buffer.from(text, 'base64'); if (raw.length === 32) { return createPublicKey({ key: Buffer.concat([SPKI_ED25519_HEADER, raw]), format: 'der', type: 'spki', }); } return createPublicKey({ key: raw, format: 'der', type: 'spki' }); } /** * A short, stable name for a key. * * It travels in the token so that a product holding several public keys knows * which one to reach for, and so that a person can tell two keys apart in a * log without handling either. */ export function fingerprint(key: PublicKeyInput): string { const der = publicKeyFrom(key).export({ format: 'der', type: 'spki' }); return createHash('sha256').update(der).digest('hex').slice(0, 16); } function decodePayload(segment: string): Licence { let parsed: unknown; try { parsed = JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')); } catch { throw new LicenceError('malformed', 'The body of this licence is not readable.'); } if (typeof parsed !== 'object' || parsed === null) { throw new LicenceError('malformed', 'The body of this licence is not readable.'); } const body = parsed as Record; for (const field of ['product', 'ref', 'to', 'from', 'term', 'key'] as const) { if (typeof body[field] !== 'string' || body[field] === '') { throw new LicenceError('malformed', `This licence has no ${field}.`); } } const licence: Licence = { product: body.product as string, ref: body.ref as string, to: body.to as string, from: body.from as string, term: body.term as string, keyId: body.key as string, }; if (typeof body.note === 'string' && body.note !== '') licence.note = body.note; return licence; }