fix(license): keep signed offline keys across restarts and cloud sign-out

This commit is contained in:
Yun Chan 2026-09-28 00:53:45 +09:00
parent 95aa95e986
commit 9d5d043e8b
7 changed files with 1157 additions and 482 deletions

View file

@ -0,0 +1,212 @@
// Regression: a signed offline license key must survive (a) app restarts more than
// 30 days after activation and (b) a cloud sign-in followed by sign-out.
// A "restart" is simulated by calling initialize() again on the same service,
// which reloads every field from the license store.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { generateLicenseKeyPair, issueSignedLicenseKey } from '@d3ro/core/utils/crypto-license'
import {
getLicenseService,
resetLicenseServiceForTests,
} from '../../../src/main/services/LicenseService'
const DAY_MS = 24 * 60 * 60 * 1000
const T0 = new Date('2026-01-01T00:00:00Z').getTime()
let previousPublicKey: string | undefined
/** issueKey() generates a fresh key pair per call; remember the public key per issued key. */
const publicKeyByLicense = new Map<string, string>()
function previousKeyFor(key: string): string {
const pem = publicKeyByLicense.get(key)
if (!pem) throw new Error('unknown key')
return pem
}
function issueKey(tier: 'pro' | 'pro_plus', expiresAt: number | null): string {
const { publicKeyPem, privateKeyPem } = generateLicenseKeyPair()
process.env.D3RO_LICENSE_PUBLIC_KEY = publicKeyPem
const key = issueSignedLicenseKey(
{
licenseId: `lic-r1-11-${tier}`,
tier,
customerEmail: 'buyer@example.test',
issuedAt: T0,
expiresAt,
machineId: null,
},
privateKeyPem,
)
publicKeyByLicense.set(key, publicKeyPem)
return key
}
beforeEach(() => {
previousPublicKey = process.env.D3RO_LICENSE_PUBLIC_KEY
vi.useFakeTimers()
vi.setSystemTime(T0)
resetLicenseServiceForTests()
})
afterEach(() => {
vi.useRealTimers()
if (previousPublicKey === undefined) delete process.env.D3RO_LICENSE_PUBLIC_KEY
else process.env.D3RO_LICENSE_PUBLIC_KEY = previousPublicKey
resetLicenseServiceForTests()
})
describe('LicenseService — signed offline key lifetime (redteam r1-11)', () => {
it('keeps a one-year pro key after a restart 31 days after activation', async () => {
const key = issueKey('pro', T0 + 365 * DAY_MS)
const svc = getLicenseService()
svc.initialize()
const result = await svc.activate(key)
expect(result.success).toBe(true)
vi.setSystemTime(T0 + 31 * DAY_MS)
svc.initialize()
expect(svc.tier).toBe('pro')
expect(svc.getInfo().licenseKey).toBe(key)
expect(svc.getInfo().expiresAt).toBe(T0 + 365 * DAY_MS)
})
it('keeps a perpetual key after a restart a year later', async () => {
const key = issueKey('pro_plus', null)
const svc = getLicenseService()
svc.initialize()
await svc.activate(key)
vi.setSystemTime(T0 + 400 * DAY_MS)
svc.initialize()
expect(svc.tier).toBe('pro_plus')
expect(svc.getInfo().licenseKey).toBe(key)
})
it('drops the key once its own expiresAt has passed', async () => {
const key = issueKey('pro', T0 + 60 * DAY_MS)
const svc = getLicenseService()
svc.initialize()
await svc.activate(key)
vi.setSystemTime(T0 + 61 * DAY_MS)
svc.initialize()
expect(svc.tier).toBe('free')
expect(svc.getInfo().licenseKey).toBeNull()
})
it('a free cloud account does not demote the key, before or after a restart', async () => {
const key = issueKey('pro', T0 + 365 * DAY_MS)
const svc = getLicenseService()
svc.initialize()
await svc.activate(key)
svc.syncFromCloud('free')
expect(svc.tier).toBe('pro')
svc.initialize()
expect(svc.tier).toBe('pro')
expect(svc.getInfo().licenseKey).toBe(key)
})
it('sign-out after a pro cloud account keeps the key and its tier', async () => {
const key = issueKey('pro', T0 + 365 * DAY_MS)
const svc = getLicenseService()
svc.initialize()
await svc.activate(key)
svc.syncFromCloud('pro_plus')
expect(svc.tier).toBe('pro_plus')
svc.resetToFree()
expect(svc.tier).toBe('pro')
expect(svc.getInfo().licenseKey).toBe(key)
svc.initialize()
expect(svc.tier).toBe('pro')
expect(svc.getInfo().licenseKey).toBe(key)
})
it('a higher cloud tier wins over the key and survives a restart until sign-out', async () => {
const key = issueKey('pro', T0 + 365 * DAY_MS)
const svc = getLicenseService()
svc.initialize()
await svc.activate(key)
svc.syncFromCloud('pro_plus')
svc.initialize()
expect(svc.tier).toBe('pro_plus')
svc.resetToFree()
expect(svc.tier).toBe('pro')
})
it('emits tier-changed only when the effective tier changes', async () => {
const key = issueKey('pro', T0 + 365 * DAY_MS)
const svc = getLicenseService()
svc.initialize()
await svc.activate(key)
const tiers: string[] = []
svc.on('tier-changed', (info: { tier: string }) => tiers.push(info.tier))
svc.syncFromCloud('free')
svc.syncFromCloud('pro')
svc.syncFromCloud('pro_plus')
svc.resetToFree()
expect(tiers).toEqual(['pro_plus', 'pro'])
})
it('deactivate clears the key but keeps the cloud tier', async () => {
const key = issueKey('pro', T0 + 365 * DAY_MS)
const svc = getLicenseService()
svc.initialize()
await svc.activate(key)
svc.syncFromCloud('pro_plus')
await svc.deactivate()
expect(svc.getInfo().licenseKey).toBeNull()
expect(svc.tier).toBe('pro_plus')
svc.resetToFree()
expect(svc.tier).toBe('free')
})
it('does not erase a stored key it cannot verify at startup (e.g. verification key missing)', async () => {
const key = issueKey('pro', T0 + 365 * DAY_MS)
const svc = getLicenseService()
svc.initialize()
await svc.activate(key)
// Verification key rotated / missing: the key is not honoured, but it stays on disk.
const { publicKeyPem } = generateLicenseKeyPair()
process.env.D3RO_LICENSE_PUBLIC_KEY = publicKeyPem
svc.initialize()
expect(svc.tier).toBe('free')
// Restored verification key: the same stored key is honoured again.
process.env.D3RO_LICENSE_PUBLIC_KEY = previousKeyFor(key)
svc.initialize()
expect(svc.tier).toBe('pro')
expect(svc.getInfo().licenseKey).toBe(key)
})
it('a reverse trial still ends after 14 days', () => {
const svc = getLicenseService()
svc.initialize()
expect(svc.startTrial('trial@example.test').success).toBe(true)
expect(svc.tier).toBe('pro_plus')
vi.setSystemTime(T0 + 13 * DAY_MS)
svc.initialize()
expect(svc.tier).toBe('pro_plus')
vi.setSystemTime(T0 + 15 * DAY_MS)
svc.initialize()
expect(svc.tier).toBe('free')
expect(svc.getInfo().isTrial).toBe(false)
expect(svc.startTrial('again@example.test').success).toBe(false)
})
})

View file

@ -0,0 +1,241 @@
// Unit tests for the extracted licensing policy/entitlement rules and for LicenseService running
// against an injected in-memory store (including files written before the key/cloud split).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Feature } from '@d3ro/core/types'
import type { LicenseVerificationResult } from '@d3ro/core/utils/crypto-license'
import { generateLicenseKeyPair, issueSignedLicenseKey } from '@d3ro/core/utils/crypto-license'
import {
higherTier,
quotaLimitFor,
tierAtLeast,
upgradeTargetFor,
buildTierComparison,
} from '../../../src/main/services/license/license-policy'
import {
composeLicenseInfo,
resolveCachedCloudTier,
resolveStoredKey,
type StoredKeyRecord,
} from '../../../src/main/services/license/license-entitlement'
import { MemoryLicenseStore } from '../../../src/main/services/license/license-store'
import type { UsageRepository } from '../../../src/main/services/license/usage-repository'
import { LicenseService } from '../../../src/main/services/LicenseService'
const DAY_MS = 24 * 60 * 60 * 1000
const T0 = new Date('2026-03-01T00:00:00Z').getTime()
const EMPTY_RECORD: StoredKeyRecord = {
licenseKey: null,
keyTier: null,
activatedAt: null,
customerEmail: null,
trialExpiresAt: null,
}
function verification(overrides: Partial<LicenseVerificationResult>): LicenseVerificationResult {
return { valid: true, tier: 'pro', reason: 'valid', payload: null, message: 'ok', ...overrides }
}
const noUsage: UsageRepository = { getCount: () => 0, increment: () => undefined }
describe('license-policy', () => {
it('orders tiers and picks the higher one', () => {
expect(tierAtLeast('pro_plus', 'pro')).toBe(true)
expect(tierAtLeast('free', 'pro')).toBe(false)
expect(higherTier('pro', 'free')).toBe('pro')
expect(higherTier('free', 'pro_plus')).toBe('pro_plus')
expect(higherTier('team', 'pro')).toBe('team')
})
it('suggests the next paid tier on quota exhaustion', () => {
expect(upgradeTargetFor('free')).toBe('pro')
expect(upgradeTargetFor('pro')).toBe('pro_plus')
})
it('only PREMIUM_LLM carries a quota and local features stay unlimited', () => {
expect(quotaLimitFor('free', Feature.DICTATION)).toBeUndefined()
expect(typeof quotaLimitFor('free', Feature.PREMIUM_LLM)).toBe('number')
})
it('returns a fresh comparison table per call', () => {
const a = buildTierComparison()
const b = buildTierComparison()
expect(a).toEqual(b)
expect(a).not.toBe(b)
expect(a).toHaveLength(17)
})
})
describe('license-entitlement.resolveStoredKey', () => {
it('returns none without a stored key', () => {
expect(resolveStoredKey(EMPTY_RECORD, T0, () => verification({})).status).toBe('none')
})
it('re-verifies a signed key instead of applying a grace window', () => {
const verify = vi.fn(() => verification({ tier: 'pro_plus', payload: null }))
const res = resolveStoredKey({ ...EMPTY_RECORD, licenseKey: 'D3RO-LIC-x', activatedAt: 1 }, T0, verify)
expect(verify).toHaveBeenCalledWith('D3RO-LIC-x')
expect(res).toMatchObject({ status: 'active', entitlement: { tier: 'pro_plus', activatedAt: 1 } })
})
it('reports a key whose own expiry passed as expired', () => {
const res = resolveStoredKey({ ...EMPTY_RECORD, licenseKey: 'D3RO-LIC-x' }, T0, () =>
verification({ valid: false, reason: 'expired', tier: 'free' }),
)
expect(res).toEqual({ status: 'expired', kind: 'license' })
})
it('reports other verification failures as unverified (the key is kept)', () => {
const res = resolveStoredKey({ ...EMPTY_RECORD, licenseKey: 'D3RO-LIC-x' }, T0, () =>
verification({ valid: false, reason: 'invalid_signature', tier: 'free', message: 'no key' }),
)
expect(res).toEqual({ status: 'unverified', message: 'no key' })
})
it('handles the local reverse trial by its own expiry without calling the verifier', () => {
const verify = vi.fn(() => verification({}))
const record = { ...EMPTY_RECORD, licenseKey: 'TRIAL-PRO-PLUS-abc', trialExpiresAt: T0 + DAY_MS }
expect(resolveStoredKey(record, T0, verify)).toMatchObject({
status: 'active',
entitlement: { tier: 'pro_plus', isTrial: true },
})
expect(resolveStoredKey(record, T0 + 2 * DAY_MS, verify)).toEqual({ status: 'expired', kind: 'trial' })
expect(verify).not.toHaveBeenCalled()
})
})
describe('license-entitlement.resolveCachedCloudTier', () => {
const activePro = {
status: 'active' as const,
entitlement: {
tier: 'pro' as const,
licenseKey: 'k',
activatedAt: null,
expiresAt: null,
customerEmail: null,
isTrial: false,
trialExpiresAt: null,
},
}
it('prefers the explicitly stored cloud tier', () => {
expect(resolveCachedCloudTier('pro', 'free', { status: 'none' })).toBe('pro')
})
it('recovers a legacy cloud tier only when no key explains it', () => {
expect(resolveCachedCloudTier(null, 'pro', { status: 'none' })).toBe('pro')
expect(resolveCachedCloudTier(null, 'pro', activePro)).toBe('free')
expect(resolveCachedCloudTier(null, 'pro_plus', activePro)).toBe('pro_plus')
expect(resolveCachedCloudTier(null, 'pro', { status: 'unverified', message: '' })).toBe('free')
expect(resolveCachedCloudTier(null, null, { status: 'none' })).toBe('free')
})
it('composes the effective tier as max(key, cloud)', () => {
const info = composeLicenseInfo({
machineId: 'm',
key: activePro.entitlement,
keyVerifiedAt: 5,
cloudTier: 'free',
cloudVerifiedAt: 9,
})
expect(info.tier).toBe('pro')
expect(info.lastVerifiedAt).toBe(9)
expect(info.offlineGraceUntil).toBeNull()
})
})
describe('LicenseService with an injected store', () => {
let previousPublicKey: string | undefined
let signedKey = ''
beforeEach(() => {
previousPublicKey = process.env.D3RO_LICENSE_PUBLIC_KEY
vi.useFakeTimers()
vi.setSystemTime(T0)
const { publicKeyPem, privateKeyPem } = generateLicenseKeyPair()
process.env.D3RO_LICENSE_PUBLIC_KEY = publicKeyPem
signedKey = issueSignedLicenseKey(
{
licenseId: 'lic-legacy',
tier: 'pro',
customerEmail: 'legacy@example.test',
issuedAt: T0 - 90 * DAY_MS,
expiresAt: T0 + 275 * DAY_MS,
machineId: null,
},
privateKeyPem,
)
})
afterEach(() => {
vi.useRealTimers()
if (previousPublicKey === undefined) delete process.env.D3RO_LICENSE_PUBLIC_KEY
else process.env.D3RO_LICENSE_PUBLIC_KEY = previousPublicKey
})
it('restores a legacy file whose 30-day grace already ran out', () => {
const store = new MemoryLicenseStore()
store.writeAll({
licenseTier: 'pro',
licenseKey: signedKey,
licenseActivatedAt: T0 - 90 * DAY_MS,
licenseOfflineGraceUntil: T0 - 60 * DAY_MS,
})
const svc = new LicenseService(store, noUsage)
svc.initialize()
expect(svc.tier).toBe('pro')
expect(svc.getInfo().licenseKey).toBe(signedKey)
expect(store.read('licenseCloudTier')).toBe('free')
})
it('restores a key that a free cloud sync had hidden in a legacy file', () => {
const store = new MemoryLicenseStore()
store.writeAll({ licenseTier: 'free', licenseKey: signedKey })
const svc = new LicenseService(store, noUsage)
svc.initialize()
expect(svc.tier).toBe('pro')
expect(store.read('licenseTier')).toBe('pro')
})
it('keeps a legacy cached cloud tier until sign-out', () => {
const store = new MemoryLicenseStore()
store.writeAll({ licenseTier: 'pro_plus', licenseLastVerifiedAt: T0 - DAY_MS })
const svc = new LicenseService(store, noUsage)
svc.initialize()
expect(svc.tier).toBe('pro_plus')
expect(svc.getInfo().lastVerifiedAt).toBe(T0 - DAY_MS)
svc.resetToFree()
expect(svc.tier).toBe('free')
expect(store.read('licenseCloudTier')).toBe('free')
})
it('keeps a legacy reverse trial until its expiry', () => {
const store = new MemoryLicenseStore()
store.writeAll({
licenseTier: 'pro_plus',
licenseKey: 'TRIAL-PRO-PLUS-abcdef12',
licenseIsTrial: true,
licenseTrialExpiresAt: T0 + 3 * DAY_MS,
licenseTrialEverStarted: true,
})
const svc = new LicenseService(store, noUsage)
svc.initialize()
expect(svc.tier).toBe('pro_plus')
expect(svc.getInfo().isTrial).toBe(true)
vi.setSystemTime(T0 + 4 * DAY_MS)
svc.initialize()
expect(svc.tier).toBe('free')
expect(store.read('licenseKey')).toBeNull()
expect(store.read('licenseTrialEverStarted')).toBe(true)
})
it('records quota usage through the injected repository', () => {
const increment = vi.fn()
const svc = new LicenseService(new MemoryLicenseStore(), { getCount: () => 0, increment })
svc.initialize()
svc.consumeQuota(Feature.DICTATION)
expect(increment).not.toHaveBeenCalled()
})
})