entitlement-verify.jsjavascript
/**
* @file Pure entitlement verification — plain Node, no Electron. src/auth.js owns the
* network calls, the safeStorage cache, and the IPC surface; this module owns the
* Ed25519 signature check and the cached-payload validity rules (expiry +
* clock-rollback), so the security-critical decisions run anywhere Node runs.
* @module entitlement-verify
*/
const crypto = require('crypto');
/**
* How far backwards the wall clock may move, in milliseconds, before the cached
* entitlement is distrusted (crude clock-rollback defense — design §6: "not
* bulletproof").
* @type {number}
*/
const CLOCK_ROLLBACK_TOLERANCE_MS = 5 * 60 * 1000;
/**
* Standard 12-byte SPKI header for Ed25519. Node wants a DER/SPKI key while the server
* hands out a raw 32-byte key; prefixing with this bridges the two.
* @type {Buffer}
*/
const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
/**
* Build a Node KeyObject from the server's raw 32-byte Ed25519 public key.
* @param {string} rawKeyB64 Raw 32-byte public key, base64-encoded.
* @returns {crypto.KeyObject} Public key usable with crypto.verify.
*/
function publicKeyFromB64(rawKeyB64) {
return crypto.createPublicKey({
key: Buffer.concat([SPKI_PREFIX, Buffer.from(rawKeyB64, 'base64')]),
format: 'der',
type: 'spki',
});
}
/**
* Serialize a value EXACTLY like the server's canonical_json(): keys sorted, no
* whitespace, raw unicode. Otherwise valid signatures won't verify.
* @param {*} value Any JSON-serializable value.
* @returns {string} Canonical JSON string.
*/
function canonicalJson(value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']';
return '{' + Object.keys(value).sort()
.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k]))
.join(',') + '}';
}
/**
* Verify the Ed25519 signature on an entitlement response. The digest algorithm passed
* to crypto.verify is null because Ed25519 has no separate digest step.
* @param {?{payload: Object, signature: string}} response Entitlement response — signed
* payload plus base64 signature.
* @param {crypto.KeyObject} verifyKey Public key to verify against.
* @returns {boolean} True when the signature verifies; false for a missing payload or
* signature.
*/
function verifyEntitlement(response, verifyKey) {
if (!response?.payload || !response?.signature) return false;
return crypto.verify(
null,
Buffer.from(canonicalJson(response.payload), 'utf8'),
verifyKey,
Buffer.from(response.signature, 'base64'),
);
}
/**
* Validity rules for a cached entitlement blob: the signature must still verify, the
* clock must not have been rolled back past the tolerance (to fake freshness), and
* valid_until must not have passed. An unparseable valid_until (Date.parse → NaN) fails
* closed: every comparison against NaN is false, which would otherwise read as "never
* expires", so a missing/garbled timestamp means the cache is not trustworthy.
* @param {?{response: {payload: Object, signature: string}, lastSeenClock: number}}
* cached Cached blob — the signed response plus the wall clock last seen when it was
* stored.
* @param {crypto.KeyObject} verifyKey Public key to verify against.
* @param {number} now Current wall clock, milliseconds since epoch.
* @returns {?Object} The payload when the cache is trustworthy, null otherwise.
*/
function checkCachedEntitlement(cached, verifyKey, now) {
if (!cached || !verifyEntitlement(cached.response, verifyKey)) return null;
if (now < cached.lastSeenClock - CLOCK_ROLLBACK_TOLERANCE_MS) return null;
const validUntil = Date.parse(cached.response.payload.valid_until);
if (!Number.isFinite(validUntil) || now > validUntil) return null;
return cached.response.payload;
}
/**
* When the entitlement should be re-fetched. Normally the 24h cadence (capMs), but if
* the payload itself says the subscription state will change sooner — a trial ending, a
* paid/canceled period lapsing — refresh just after that boundary (graceMs past it, so
* the server's read-time state machine has definitely flipped). Boundaries already in
* the past are ignored: the current payload was built after them, so they're old news.
* floorMs stops a just-passed boundary from hot-looping the scheduler.
* @param {?Object} payload Entitlement payload; trial_ends_at and current_period_end
* (ISO timestamps) are the boundaries considered.
* @param {number} now Current wall clock, milliseconds since epoch.
* @param {Object} [opts]
* @param {number} [opts.capMs=86400000] Maximum delay — the normal 24h cadence.
* @param {number} [opts.floorMs=30000] Minimum delay.
* @param {number} [opts.graceMs=60000] Delay added past a boundary before refreshing.
* @returns {number} Delay until the next refresh, in milliseconds.
*/
function nextRefreshDelayMs(payload, now, {
capMs = 24 * 60 * 60 * 1000,
floorMs = 30 * 1000,
graceMs = 60 * 1000,
} = {}) {
let delay = capMs;
for (const iso of [payload?.trial_ends_at, payload?.current_period_end]) {
if (!iso) continue;
const boundary = Date.parse(iso);
if (!Number.isFinite(boundary)) continue;
const untilRefresh = boundary - now + graceMs;
if (untilRefresh > 0 && untilRefresh < delay) delay = untilRefresh;
}
return Math.max(floorMs, delay);
}
module.exports = {
CLOCK_ROLLBACK_TOLERANCE_MS,
publicKeyFromB64,
canonicalJson,
verifyEntitlement,
checkCachedEntitlement,
nextRefreshDelayMs,
};