#!/usr/bin/env node /** * DashCaddy License Code Generator * * Admin-only CLI tool for generating license codes. * NOT shipped with the product — runs only on the developer's machine. * * Usage: * node license-keygen.js --duration 365 --count 10 * node license-keygen.js --duration 30 --count 1 --output codes.txt * node license-keygen.js --verify DC-XXXXX-XXXXX-XXXXX-XXXXX * node license-keygen.js --init-secret */ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); // Master secret file — lives only on admin machine, NEVER shipped. // Default is `path.join(__dirname, '.license-secret')`. The path is // overridable via the `LICENSE_SECRET_FILE` env var so the CLI can be // driven from CI / isolated test environments without polluting the // source directory (mirrors the `LICENSE_COUNTER_FILE` override pattern). // The Stripe bridge uses the same env var to point at its own secret file // on the bridge host. function _defaultSecretFile() { return process.env.LICENSE_SECRET_FILE || path.join(__dirname, '.license-secret'); } // License code format: DC-AAAAA-BBBBB-CCCCC-DDDDD-EEEEE // Encodes: version(4bit) + duration_days(12bit) + code_id(32bit) + created_ts(32bit) + hmac(40bit) // Total: 120 bits = 15 bytes, base32-encoded into 5 groups of 5 chars // (25 base32 chars = 125 bits, comfortably fits 120 bits of data) const VALID_DURATIONS = [30, 90, 180, 365]; const LIFETIME_DURATION = 0; // Admin-only, not publicly available const VERSION = 1; // Base32 alphabet (Crockford variant — no I/L/O/U to avoid confusion) const BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; function base32Encode(buffer) { let bits = ''; for (const byte of buffer) { bits += byte.toString(2).padStart(8, '0'); } // Pad to multiple of 5 while (bits.length % 5 !== 0) bits += '0'; let result = ''; for (let i = 0; i < bits.length; i += 5) { const index = parseInt(bits.substring(i, i + 5), 2); result += BASE32[index]; } return result; } function base32Decode(str) { let bits = ''; for (const char of str.toUpperCase()) { const index = BASE32.indexOf(char); if (index === -1) throw new Error(`Invalid base32 character: ${char}`); bits += index.toString(2).padStart(5, '0'); } const bytes = []; for (let i = 0; i + 8 <= bits.length; i += 8) { bytes.push(parseInt(bits.substring(i, i + 8), 2)); } return Buffer.from(bytes); } function getSecret() { const file = _defaultSecretFile(); if (!fs.existsSync(file)) { console.error('No master secret found at', file); console.error('Run with --init-secret first.'); process.exit(1); } return fs.readFileSync(file, 'utf8').trim(); } // Counter location: the default is `path.join(__dirname, '.license-counter')`. // That's adjacent to this source file on the admin machine (not the secret // file — the secret and counter share a directory on the developer's // workstation, but they are independent files). The CLI does not merge them. // When this module is required from a packaged/installed location where // __dirname might be read-only, override the counter location via the // `LICENSE_COUNTER_FILE` env var. The Stripe bridge uses this same path. function _defaultCounterFile() { return process.env.LICENSE_COUNTER_FILE || path.join(__dirname, '.license-counter'); } // Atomic counter write — write to a uniquely-named .tmp then rename. The // .tmp suffix includes pid + Date.now() + Math.random so two concurrent // calls in overlapping event-loop ticks (e.g. a Stripe webhook fan-out) // can't collide on the temp name. POSIX rename is atomic on the same // filesystem, so the live counter file is never observed in a half-written // state. If writeFileSync throws, we re-throw without renaming — the // original counter file is intact. If renameSync throws, we attempt to // unlink the .tmp so it doesn't accumulate. function _atomicWriteCounter(counterFile, value) { const tmpFile = `${counterFile}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; try { fs.writeFileSync(tmpFile, String(value)); } catch (err) { throw new Error(`generateCodes: failed to write counter tmp file ${tmpFile}: ${err.message}`); } try { fs.renameSync(tmpFile, counterFile); } catch (err) { try { fs.unlinkSync(tmpFile); } catch (_) { /* best effort cleanup */ } throw new Error(`generateCodes: failed to rename counter tmp to ${counterFile}: ${err.message}`); } } // Concurrency note: this module is single-threaded JavaScript. Two // synchronous calls to generateCodes() within the same event-loop tick // cannot interleave — fs.*Sync blocks the thread and the second call runs // only after the first returns. The "atomic" part of the counter write // protects against a process crash between writeFileSync and renameSync // (the original counter file is intact because rename never happened) // and against OS-level write atomicity. It does NOT protect against a // concurrent process — license-keygen.js is a single-instance admin tool // and must not be invoked from multiple processes simultaneously. // Callers needing cross-process safety (which is none currently) would // need OS-level locking via fcntl or flock — out of scope. /** * Programmatic equivalent of the CLI's "generate codes" path. * * Differs from the CLI in two ways: * 1. No console output — returns the resulting array. * 2. Persists the counter file atomically (write to a uniquely-named * .tmp, rename) so a crash mid-write doesn't leave the counter in a * half-bumped state, and so concurrent calls don't collide on the * same .tmp name. * * Concurrency: relies on Node's single-threaded event loop. Two * synchronous calls in the same tick cannot interleave — the second call * reads the post-write counter value. The atomic write helper protects * against process crashes between writeFileSync and renameSync, and the * unique .tmp suffix prevents filename collisions across ticks. Cross-process * races are still possible — license-keygen.js is a single-instance admin * tool, so callers must not invoke it from multiple processes simultaneously. * * Returns synchronously. The underlying counter allocator uses fs.*Sync, * so the function never throws asynchronously. Wrap with Promise.resolve() * if your caller needs a Promise. * * @param {Object} opts * @param {string} opts.secret The master secret (hex string). Callers * are responsible for loading it via * loadSecret() or getSecret(). * @param {number} opts.durationDays 30, 90, 180, 365, or 0 for LIFETIME. * Validated against VALID_DURATIONS / LIFETIME. * @param {number} [opts.count=1] Number of codes to mint. * @param {number} [opts.startId] Override the auto counter. If omitted, * reads + increments the counter file. * @param {string} [opts.counterFile] Override the counter file path. * Defaults to env LICENSE_COUNTER_FILE or * path.join(__dirname, '.license-counter'). * @returns {Array<{code: string, codeId: number, durationDays: number}>} */ // Throws on bad opts. Returns { secret, durationDays, count } with defaults applied. function _validateGenerateOpts(opts) { if (!opts || !opts.secret || typeof opts.secret !== 'string') { throw new Error('generateCodes: secret is required'); } const { secret, count = 1 } = opts; const { durationDays } = opts; // LIFETIME (0) is accepted; non-LIFETIME must be in the allowed list. if (durationDays !== 0 && !VALID_DURATIONS.includes(durationDays)) { throw new Error(`generateCodes: invalid duration ${durationDays}. Valid: ${VALID_DURATIONS.join(', ')}`); } if (!Number.isInteger(count) || count < 1 || count > 10000) { throw new Error(`generateCodes: invalid count ${count} (must be 1..10000)`); } return { secret, durationDays, count }; } // Resolves the next startId. startIdProvided=true means the caller passed // opts.startId (even if the value is invalid — validation happens here). // Reads the counter file on the auto path; throws on parse/IO error. function _resolveStartId(startIdProvided, overrideStartId, counterFile) { if (startIdProvided) { if (!Number.isInteger(overrideStartId) || overrideStartId < 0 || overrideStartId > 0xFFFFFFFF) { throw new Error(`generateCodes: startId out of range or non-integer (must be 0..0xFFFFFFFF, got ${overrideStartId})`); } return overrideStartId; } try { if (fs.existsSync(counterFile)) { const raw = fs.readFileSync(counterFile, 'utf8').trim(); if (!/^\d+$/.test(raw)) { throw new Error(`counter file ${counterFile} contains non-numeric value '${raw}'`); } return parseInt(raw, 10) + 1; } return 1; } catch (err) { if (err.message && err.message.startsWith('counter file ')) throw err; throw new Error(`generateCodes: failed to read counter file ${counterFile}: ${err.message}`); } } function generateCodes(opts) { const { secret, durationDays, count } = _validateGenerateOpts(opts); const overrideCounterFile = opts && opts.counterFile; const counterFile = overrideCounterFile || _defaultCounterFile(); // Validate startId BEFORE selecting the allocation path. Any explicitly // supplied startId (including floats, NaN, null, numeric strings) must // either be a valid integer in range or throw — we use // Object.prototype.hasOwnProperty to distinguish "caller passed startId" // from "caller omitted startId" so the overrideStartId validation runs // regardless of value. const startIdProvided = opts && Object.prototype.hasOwnProperty.call(opts, 'startId'); const overrideStartId = startIdProvided ? opts.startId : undefined; const startId = _resolveStartId(startIdProvided, overrideStartId, counterFile); // Validate that the requested range fits in the code_id field (32 bits). const lastCodeId = startId + count - 1; if (lastCodeId > 0xFFFFFFFF) { throw new Error(`generateCodes: codeId range exceeds 32-bit limit (startId=${startId}, count=${count}, lastCodeId=${lastCodeId})`); } const codes = []; for (let i = 0; i < count; i++) { const codeId = startId + i; const code = generateCode(secret, durationDays, codeId); codes.push({ code, codeId, durationDays }); } // Persist the new counter value (skipped when startId was overridden). if (!startIdProvided) { _atomicWriteCounter(counterFile, lastCodeId); } return codes; } /** * Load the master secret from disk. Exported so the Stripe bridge can * call it without going through getSecret() (which prints to stderr and * exits on missing-secret — wrong semantics for a library call). * * @param {string} [overridePath] Defaults to the SECRET_FILE constant. * @returns {string} The hex secret. * @throws If the file is missing or unreadable. */ function loadSecret(overridePath) { const file = overridePath || _defaultSecretFile(); if (!fs.existsSync(file)) { throw new Error(`Master secret file not found at ${file}. Run --init-secret first.`); } return fs.readFileSync(file, 'utf8').trim(); } function initSecret() { const file = _defaultSecretFile(); if (fs.existsSync(file)) { console.error('Master secret already exists at', file); console.error('Delete it first if you want to regenerate (WARNING: invalidates all existing codes).'); process.exit(1); } const secret = crypto.randomBytes(32).toString('hex'); fs.writeFileSync(file, secret, { mode: 0o600 }); console.log('Master secret generated and saved to', file); console.log('KEEP THIS FILE SAFE. It is needed to generate and validate all license codes.'); console.log('DO NOT ship this file with the product.'); } function generateCode(secret, durationDays, codeId) { // Pack payload: version(4b) + duration_days(12b) + code_id(32b) + created_ts(32b) = 80 bits = 10 bytes const payload = Buffer.alloc(10); // Byte 0-1: version (4 bits) + duration (12 bits) = 16 bits const versionAndDuration = ((VERSION & 0x0F) << 12) | (durationDays & 0x0FFF); payload.writeUInt16BE(versionAndDuration, 0); // Byte 2-5: code_id (32 bits) payload.writeUInt32BE(codeId, 2); // Byte 6-9: created timestamp (32 bits, seconds since epoch) const createdTs = Math.floor(Date.now() / 1000); payload.writeUInt32BE(createdTs, 6); // HMAC the payload to get signature const hmac = crypto.createHmac('sha256', secret).update(payload).digest(); // Take first 5 bytes of HMAC (40 bits) — fits exactly in 25 base32 chars with 10-byte payload const signature = hmac.subarray(0, 5); // Combine: payload (10 bytes) + signature (5 bytes) = 15 bytes = 120 bits // 25 base32 chars = 125 bits, comfortably fits 120 bits const combined = Buffer.concat([payload, signature]); let encoded = base32Encode(combined); while (encoded.length < 25) encoded += '0'; encoded = encoded.substring(0, 25); const groups = []; for (let i = 0; i < 25; i += 5) { groups.push(encoded.substring(i, i + 5)); } return `DC-${groups.join('-')}`; } function parseCode(code) { // Strip prefix and dashes const cleaned = code.replace(/^DC-/, '').replace(/-/g, ''); if (cleaned.length !== 25) { throw new Error(`Invalid code length: expected 25 base32 chars, got ${cleaned.length}`); } // Decode base32 — 25 chars = 125 bits = 15 full bytes const decoded = base32Decode(cleaned); if (decoded.length < 15) { const padded = Buffer.alloc(15); decoded.copy(padded); return parsePayload(padded); } return parsePayload(decoded.subarray(0, 15)); } function parsePayload(buffer) { const payload = buffer.subarray(0, 10); const signature = buffer.subarray(10, 15); const versionAndDuration = payload.readUInt16BE(0); const version = (versionAndDuration >> 12) & 0x0F; const durationDays = versionAndDuration & 0x0FFF; const codeId = payload.readUInt32BE(2); const createdTs = payload.readUInt32BE(6); return { version, durationDays, codeId, createdTs, payload, signature }; } function verifyCode(secret, code) { try { const { version, durationDays, codeId, createdTs, payload, signature } = parseCode(code); // Verify HMAC (5-byte signature) const expectedHmac = crypto.createHmac('sha256', secret).update(payload).digest(); const expectedSig = expectedHmac.subarray(0, 5); if (!crypto.timingSafeEqual(signature, expectedSig)) { return { valid: false, reason: 'Invalid signature — code is forged or corrupted' }; } if (version !== VERSION) { return { valid: false, reason: `Unsupported version: ${version}` }; } // Accept lifetime (0) and standard durations if (durationDays !== LIFETIME_DURATION && !VALID_DURATIONS.includes(durationDays)) { return { valid: false, reason: `Invalid duration: ${durationDays} days` }; } const createdDate = new Date(createdTs * 1000); const isLifetime = durationDays === LIFETIME_DURATION; const expiresDate = isLifetime ? null : new Date(createdTs * 1000 + durationDays * 86400000); return { valid: true, version, durationDays, codeId, createdAt: createdDate.toISOString(), expiresAt: isLifetime ? null : expiresDate.toISOString(), expired: isLifetime ? false : Date.now() > expiresDate.getTime() }; } catch (error) { return { valid: false, reason: error.message }; } } // CLI function main() { const args = process.argv.slice(2); if (args.includes('--help') || args.length === 0) { console.log(` DashCaddy License Code Generator Usage: node license-keygen.js --init-secret Initialize master secret (first time only) node license-keygen.js --duration [options] Generate Pro license codes node license-keygen.js --lifetime [options] Generate a LIFETIME code (creator-only) node license-keygen.js --verify Verify a license code node license-keygen.js --decode Decode and display code details Options: --duration Code validity: 30, 90, 180, or 365 days (required for generation, mutually exclusive with --lifetime) --tier Tier label; only 'pro' is supported (optional label; valid in combination with --duration or --lifetime) --lifetime Generate a LIFETIME code — REJECTED at activation on production hosts --count Number of codes to generate (default: 1) --start-id Starting code ID (default: auto from counter file) --output Write codes to file instead of stdout --json Output as JSON Valid durations: ${VALID_DURATIONS.join(', ')} days Valid tiers: pro (cosmetic alias; does not change generation behavior) `); process.exit(0); } if (args.includes('--init-secret')) { initSecret(); return; } if (args.includes('--verify') || args.includes('--decode')) { const codeIndex = args.indexOf('--verify') !== -1 ? args.indexOf('--verify') : args.indexOf('--decode'); const code = args[codeIndex + 1]; if (!code) { console.error('Please provide a code to verify.'); process.exit(1); } const secret = getSecret(); const result = verifyCode(secret, code); if (args.includes('--json')) { console.log(JSON.stringify(result, null, 2)); } else if (result.valid) { const isLifetime = result.durationDays === 0; console.log('Code is VALID'); console.log(` Version: ${result.version}`); console.log(` Duration: ${isLifetime ? 'LIFETIME' : result.durationDays + ' days'}`); console.log(` Code ID: ${result.codeId}`); console.log(` Created: ${result.createdAt}`); console.log(` Expires: ${isLifetime ? 'NEVER' : result.expiresAt}`); console.log(` Status: ${isLifetime ? 'LIFETIME' : (result.expired ? 'EXPIRED' : 'ACTIVE')}`); } else { console.log('Code is INVALID'); console.log(` Reason: ${result.reason}`); } return; } // Generate codes const isLifetime = args.includes('--lifetime'); // --tier is a cosmetic label right now (only 'pro' is supported). It does // NOT change generation behavior — every code minted with --duration is // already a Pro code, and --lifetime is enforced separately at activation // time. The flag exists to make operator intent obvious in shell history // and to reserve a forward-compatible hook for a future tier that needs // to alter code generation (e.g. a 'free' tier with a different prefix). // It is only meaningful in combination with --duration or --lifetime — // by itself, generation still requires one of those flags. const tierIndex = args.indexOf('--tier'); if (tierIndex !== -1) { const tier = (args[tierIndex + 1] || '').toLowerCase(); if (tier !== 'pro') { console.error(`Invalid tier: '${tier}'. Supported: pro.`); process.exit(1); } } const durationIndex = args.indexOf('--duration'); if (!isLifetime && durationIndex === -1) { console.error('--duration is required (or use --lifetime). Use --help for usage.'); process.exit(1); } if (isLifetime && durationIndex !== -1) { console.error('--lifetime and --duration are mutually exclusive.'); process.exit(1); } const duration = isLifetime ? LIFETIME_DURATION : parseInt(args[durationIndex + 1]); if (!isLifetime && !VALID_DURATIONS.includes(duration)) { console.error(`Invalid duration: ${duration}. Valid: ${VALID_DURATIONS.join(', ')}`); process.exit(1); } const countIndex = args.indexOf('--count'); const count = countIndex !== -1 ? parseInt(args[countIndex + 1]) : 1; const startIdIndex = args.indexOf('--start-id'); const overrideStartId = startIdIndex !== -1 ? parseInt(args[startIdIndex + 1]) : undefined; const secret = getSecret(); // Only pass startId when --start-id was supplied on the CLI. generateCodes // uses Object.prototype.hasOwnProperty.call(opts, 'startId') to distinguish // "caller passed startId" from "caller omitted startId" and rejects // non-integer values. Passing startId: undefined would mean "caller passed // undefined", which the validation path then rejects. const generateOpts = { secret, durationDays: duration, count }; if (overrideStartId !== undefined) { generateOpts.startId = overrideStartId; } const codes = generateCodes(generateOpts); // Output const outputIndex = args.indexOf('--output'); if (args.includes('--json')) { const output = JSON.stringify(codes, null, 2); if (outputIndex !== -1) { fs.writeFileSync(args[outputIndex + 1], output); console.log(`${count} code(s) written to ${args[outputIndex + 1]}`); } else { console.log(output); } } else { const lines = codes.map(c => `${c.code} (${c.durationDays === 0 ? 'LIFETIME' : c.durationDays + ' days'}, ID: ${c.codeId})`); if (outputIndex !== -1) { fs.writeFileSync(args[outputIndex + 1], codes.map(c => c.code).join('\n') + '\n'); console.log(`${count} code(s) written to ${args[outputIndex + 1]}`); } else { lines.forEach(l => console.log(l)); } } const lastCodeId = codes[codes.length - 1].codeId; console.log(`\nGenerated ${count} code(s) for ${duration === 0 ? 'LIFETIME' : duration + ' days'}. Next ID: ${lastCodeId + 1}`); } // Also export for use by license-manager.js and the Stripe webhook bridge. // `generateCode` is exported so the bridge can mint codes in-process rather // than spawning a child process (faster, atomic counter, easier to test). // `generateCodes` (note the trailing 's') is the bulk-friendly wrapper that // handles the counter-file write and returns a stable array of {code, codeId, // durationDays} records — used by the bridge when one Stripe event must // produce one code (typical case is just 1, but the API is uniform). module.exports = { verifyCode, parseCode, generateCode, generateCodes, loadSecret, VALID_DURATIONS, VERSION, }; if (require.main === module) { main(); }