Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 62 additions & 12 deletions apps/desktop/src/main/services/fileLicenseStorage.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -58,7 +79,36 @@ export class FileLicenseStorage implements LicenseStorage {
}

async readSubscriptionData(): Promise<StoredSubscriptionData | null> {
return readJsonOrNull<StoredSubscriptionData>(this.subscriptionPath);
const cached = await readJsonOrNull<StoredSubscriptionData>(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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Derive subscription data from the verified envelope

When a signed cache is present, this returns the unsigned sibling subscription even though only signedEnvelope.payload.subscription was verified. A local edit can keep a valid envelope intact but change subscription.currentPeriodEnd/status/plan (and cacheExpiresAt) so isCachedSubscriptionValid(cached) and computeLicenseState trust tampered data for the envelope replay window; the storage layer should either return cached with subscription: result.subscription (and appropriate cache metadata) or reject mismatches.

Useful? React with 👍 / 👎.

}

async writeSubscriptionData(data: StoredSubscriptionData): Promise<void> {
Expand Down
10 changes: 9 additions & 1 deletion packages/licensing/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down