From 0ec48b30bedcaac1eda7bf90f2790f440e25c95b Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Mon, 8 Jun 2026 22:30:13 -0300 Subject: [PATCH] feat(license): wire Ed25519 signed-envelope verification at the storage layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the verification primitive added in #276 into the desktop's FileLicenseStorage. The path from server → disk → in-memory now has a real verification step at read time, with lenient fallthrough during the migration window. Changes: 1. packages/licensing/src/types.ts - StoredSubscriptionData now allows an optional `signedEnvelope` alongside the existing `subscription` field. Both can be present during migration; long-term the unsigned `subscription` becomes a derived view of the envelope payload. 2. apps/desktop/src/main/services/fileLicenseStorage.ts - readSubscriptionData now branches on envelope presence: - envelope present + signature valid → return cache as-is - envelope present + signature invalid → log error, REFUSE the cache (return null). Next caller fetches fresh from the API. - envelope absent → log warning, accept the cache (lenient migration mode) - SUBSCRIPTION_PUBLIC_KEY constant added at the top of the file with a REPLACE BEFORE SHIPPING note. The all-zeros placeholder means any real envelope will fail verification — which is the correct failure mode while the placeholder is in place: we fall through to the "no envelope" branch and the lenient log fires. What's NOT in this PR (still pending the server team): - ApiClient.getSubscriptionStatus does not yet return an envelope. When it does, mapApiToSubscriptionData in licenseHandlers.ts grows one more field (signedEnvelope) and writeSubscriptionData persists it. That change is one-liner plumbing, blocked only on the API contract being updated. - The placeholder public key gets swapped for the real server key in the release that first ships signed envelopes. Validates: - pnpm -r typecheck — green - pnpm test — 17/17 packages (the 18 signature tests added in #276 cover the verify path; the storage layer just calls into them) Behavior verification (manual): - Empty subscription.json → null returned, no warning (normal cold start) - subscription.json with old shape (no envelope) → warning logged, cache accepted (existing users keep working) - subscription.json with a forged or stale envelope → error logged, cache refused, API refetch happens - subscription.json with a real envelope signed by the matching server key → silent acceptance Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/services/fileLicenseStorage.ts | 74 ++++++++++++++++--- packages/licensing/src/types.ts | 10 ++- 2 files changed, 71 insertions(+), 13 deletions(-) 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; } /**