diff --git a/apps/desktop/src/main/services/fileLicenseStorage.ts b/apps/desktop/src/main/services/fileLicenseStorage.ts index 753a0894..f92e4240 100644 --- a/apps/desktop/src/main/services/fileLicenseStorage.ts +++ b/apps/desktop/src/main/services/fileLicenseStorage.ts @@ -1,28 +1,49 @@ /** * File-backed implementation of @readied/licensing's LicenseStorage. * - * Lives alongside the other services. Persists three small JSON files - * under the user's data directory: + * Persists three small JSON files under the user's data directory: * * license.json — legacy LicenseFile (StoredLicenseData) * trial.json — local trial start (StoredTrialData) * subscription.json — cached subscription state (StoredSubscriptionData) * - * Note: trial.json is unsigned by design (see packages/licensing/README.md). - * subscription.json will move to a signed-envelope wire format once the - * server emits SignedSubscriptionEnvelope (see @readied/licensing - * verifySubscriptionSignature). Until then, the cache is best-effort. + * Subscription verification (Ed25519): + * - If the persisted cache contains `signedEnvelope`, the read path + * verifies it via @readied/licensing's verifySubscriptionSignature + * before returning. An invalid envelope causes the cache to be + * refused (read returns null) so the next call falls through to a + * fresh fetch from the API. + * - If the persisted cache has NO `signedEnvelope`, we accept it and + * log a structured warning. This is the migration window: once the + * server reliably emits envelopes for N releases, we can flip to + * strict mode (refuse unsigned caches). + * - trial.json is unsigned by design (see packages/licensing/README.md). */ import { readFile, writeFile, unlink } from 'fs/promises'; import { existsSync } from 'fs'; import { join } from 'path'; -import type { - LicenseStorage, - StoredLicenseData, - StoredTrialData, - StoredSubscriptionData, +import { + verifySubscriptionSignature, + type LicenseStorage, + type StoredLicenseData, + type StoredTrialData, + type StoredSubscriptionData, } from '@readied/licensing'; +import { loggers } from '../logger'; + +/** + * Ed25519 public key used to verify SignedSubscriptionEnvelope payloads. + * + * REPLACE BEFORE SHIPPING signed subscriptions. The all-zeros placeholder + * means verification WILL fail for any real signed envelope — that's the + * desired failure mode while the server isn't yet emitting envelopes + * (we fall through to "no envelope" and use the lenient path). + * + * The matching private key lives only on the licensing server. Never + * commit it. + */ +const SUBSCRIPTION_PUBLIC_KEY = '0000000000000000000000000000000000000000000000000000000000000000'; export class FileLicenseStorage implements LicenseStorage { private readonly licensePath: string; @@ -58,7 +79,36 @@ export class FileLicenseStorage implements LicenseStorage { } async readSubscriptionData(): Promise { - return readJsonOrNull(this.subscriptionPath); + const cached = await readJsonOrNull(this.subscriptionPath); + if (!cached) return null; + + if (!cached.signedEnvelope) { + // Migration window: no envelope on disk. Accept the cache, log so + // operators can see when the population is fully migrated. + loggers + .license() + .warn( + { hasSubscriptionId: Boolean(cached.subscription?.subscriptionId) }, + 'subscription cache has no signed envelope — running in lenient mode' + ); + return cached; + } + + const result = await verifySubscriptionSignature(cached.signedEnvelope, { + publicKey: SUBSCRIPTION_PUBLIC_KEY, + }); + if (!result.valid) { + loggers + .license() + .error( + { error: result.error }, + 'subscription cache envelope failed verification — refusing cache, will refetch' + ); + // Refuse the cache. The next caller will fetch from the API. + return null; + } + + return cached; } async writeSubscriptionData(data: StoredSubscriptionData): Promise { diff --git a/packages/licensing/src/types.ts b/packages/licensing/src/types.ts index 1e1016f5..f9635a31 100644 --- a/packages/licensing/src/types.ts +++ b/packages/licensing/src/types.ts @@ -75,12 +75,20 @@ export interface StoredTrialData { } /** - * Stored subscription data (cached locally) + * Stored subscription data (cached locally). + * + * `signedEnvelope` is the server-signed source of truth when present. + * `subscription` is the unsigned view derived from it (or, during the + * migration period before the server emits signed envelopes, the raw + * API response). Clients that have an envelope MUST verify it before + * trusting the cached subscription — see verifySubscriptionSignature. */ export interface StoredSubscriptionData { readonly subscription: SubscriptionInfo; readonly lastVerified: string; // ISO 8601 readonly cacheExpiresAt: string; // ISO 8601 + /** Signed envelope from the server. Optional during migration. */ + readonly signedEnvelope?: SignedSubscriptionEnvelope; } /**