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,
};